Stream sync, countdown timer, playback state fix, buffer bloat detection

Multi-pronged attempt to fix cumulative lag between audio playback,
now-playing display, and browser notifications:

== Synchronization (work in progress) ==
- Server-side time-based metadata delay via get-listener-now-playing
  with configurable *browser-buffer-seconds* parameter
- First track after restart syncs perfectly; subsequent tracks drift
  due to browser audio buffer bloat (buffer grows unbounded over time)
- Added client-side buffer bloat detection: monitors audio.buffered
  vs audio.currentTime and auto-reconnects when buffer exceeds 15s
- Added [STREAM-SYNC], [NOTIFY], [BUFFER] diagnostic logging to
  browser console for ongoing diagnosis

== Countdown timer ==
- Server exposes remaining seconds via pipeline-track-remaining
- now-playing JSON API includes 'remaining' field adjusted for
  browser buffer delay
- Player frame shows [mm:ss] countdown next to track title
- Main page now-playing area also shows countdown with its own
  polling (15s interval) and local 1-second ticker
- LASS styles for .track-countdown and .track-countdown-mini

== Playback state fix ==
- Fixed save-playback-state saving one track ahead of what was
  actually playing (was saving the track loading during crossfade)
- Uses *pending-save-file* with one-track delay so the saved state
  reflects the track that was actually heard

== Notifications ==
- All notification conditions working correctly per diagnostic logs
- show-track-notification logs supported/permission/enabled/last/title
- Notifications fire consistently on track changes
This commit is contained in:
Glenn Thompson 2026-04-09 16:32:58 +01:00
parent 76d331248b
commit 10c75f04e1
10 changed files with 303 additions and 24 deletions

@ -1 +1 @@
Subproject commit 23b5ee1e35e6c967cab12751ac7b8930d21b3307 Subproject commit 8a5dde598370b13965317f456999a85096832be6

View File

@ -120,12 +120,14 @@
(artist (getf parsed :artist)) (artist (getf parsed :artist))
(song (getf parsed :song)) (song (getf parsed :song))
(search-url (generate-music-search-url artist song))) (search-url (generate-music-search-url artist song)))
(let ((remaining (cdr (assoc :remaining now-playing-stats))))
(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)))) ("search_url" . ,search-url)
,@(when remaining `(("remaining" . ,remaining)))))))
(api-output `(("status" . "offline") (api-output `(("status" . "offline")
("title" . "Stream Offline") ("title" . "Stream Offline")
("track_id" . nil))))))) ("track_id" . nil)))))))

View File

@ -866,7 +866,52 @@
(ps:chain console (log "Could not load recent requests:" error)))))))) (ps:chain console (log "Could not load recent requests:" error))))))))
;; Load recent requests on page load ;; Load recent requests on page load
(load-recent-requests))) (load-recent-requests)
;; Main page countdown timer
(defvar *main-remaining* nil)
(defun format-countdown (seconds)
(let ((m (ps:chain -math (floor (/ seconds 60))))
(s (ps:chain -math (floor (mod seconds 60)))))
(+ (if (< m 10) (+ "0" m) m) ":" (if (< s 10) (+ "0" s) s))))
(defun poll-now-playing ()
(let ((mount (or (ps:chain local-storage (get-item "stream-mount")) "asteroid.mp3")))
(ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when data
(let ((title (or (ps:@ data data title) (ps:@ data title)))
(remaining (or (ps:@ data data remaining) (ps:@ data remaining)))
(listeners (or (ps:@ data data listeners) (ps:@ data listeners)))
(title-el (ps:chain document (get-element-by-id "current-track-title")))
(listener-el (ps:chain document (get-element-by-id "current-listeners"))))
(when (and title-el title)
(setf (ps:@ title-el text-content) title))
(when (and listener-el listeners)
(setf (ps:@ listener-el text-content) listeners))
(when remaining
(setf *main-remaining* remaining))))))
(catch (lambda (error) nil)))))
;; Start polling and countdown ticker on the main page
(set-timeout poll-now-playing 2000)
(set-interval poll-now-playing 15000)
(set-interval
(lambda ()
(let ((el (ps:chain document (get-element-by-id "track-countdown-main"))))
(when el
(if (and *main-remaining* (> *main-remaining* 0))
(progn
(decf *main-remaining*)
(setf (ps:@ el text-content) (+ "[" (format-countdown *main-remaining*) "]")))
(setf (ps:@ el text-content) "")))))
1000)))
"Compiled JavaScript for front-page - generated at load time") "Compiled JavaScript for front-page - generated at load time")
(defun generate-front-page-js () (defun generate-front-page-js ()

View File

@ -336,6 +336,30 @@
;; Track last notified title to avoid duplicate notifications ;; Track last notified title to avoid duplicate notifications
(defvar *last-notified-title* nil) (defvar *last-notified-title* nil)
;; Countdown timer state
(defvar *track-remaining-seconds* nil)
(defvar *countdown-interval* nil)
(defun format-countdown (seconds)
(let ((m (ps:chain -math (floor (/ seconds 60))))
(s (ps:chain -math (floor (mod seconds 60)))))
(+ (if (< m 10) (+ "0" m) m) ":" (if (< s 10) (+ "0" s) s))))
(defun start-countdown-ticker ()
(when *countdown-interval*
(clear-interval *countdown-interval*))
(setf *countdown-interval*
(set-interval
(lambda ()
(let ((el (ps:chain document (get-element-by-id "track-countdown"))))
(when el
(if (and *track-remaining-seconds* (> *track-remaining-seconds* 0))
(progn
(decf *track-remaining-seconds*)
(setf (ps:@ el text-content) (+ "[" (format-countdown *track-remaining-seconds*) "]")))
(setf (ps:@ el text-content) "")))))
1000)))
;; Check if notifications are enabled in localStorage ;; Check if notifications are enabled in localStorage
(defun notifications-enabled-p () (defun notifications-enabled-p ()
(= (ps:chain local-storage (get-item "notifications-enabled")) "true")) (= (ps:chain local-storage (get-item "notifications-enabled")) "true"))
@ -410,6 +434,12 @@
;; Show a system notification for track change ;; Show a system notification for track change
(defun show-track-notification (title body) (defun show-track-notification (title body)
(ps:chain console (log "[NOTIFY] show-track-notification called:"
"supported=" (notifications-supported-p)
"permission=" (get-notification-permission)
"enabled=" (notifications-enabled-p)
"last=" *last-notified-title*
"title=" title))
(when (and (notifications-supported-p) (when (and (notifications-supported-p)
(= (get-notification-permission) "granted") (= (get-notification-permission) "granted")
(notifications-enabled-p) (notifications-enabled-p)
@ -422,6 +452,7 @@
:tag "asteroid-track-change" :tag "asteroid-track-change"
:renotify true :renotify true
:silent false))))) :silent false)))))
(ps:chain console (log "[NOTIFY] Notification created successfully"))
;; Auto-close after 5 seconds ;; Auto-close after 5 seconds
(set-timeout (lambda () (ps:chain notification (close))) 5000) (set-timeout (lambda () (ps:chain notification (close))) 5000)
;; Click to focus the window ;; Click to focus the window
@ -430,7 +461,7 @@
(ps:chain window (focus)) (ps:chain window (focus))
(ps:chain notification (close))))) (ps:chain notification (close)))))
(:catch (e) (:catch (e)
(ps:chain console (log "Notification error:" e)))))) (ps:chain console (log "[NOTIFY] Notification error:" e))))))
;; Notify track change (called from update-mini-now-playing) ;; Notify track change (called from update-mini-now-playing)
(defun notify-track-change (title) (defun notify-track-change (title)
@ -514,16 +545,15 @@
(when el (when el
;; Check if track changed and record to history + notify ;; Check if track changed and record to history + notify
(when (not (= (ps:@ el text-content) title)) (when (not (= (ps:@ el text-content) title))
(ps:chain console (log "[STREAM-SYNC] Title changed:" title))
(record-track-listen title) (record-track-listen title)
(notify-track-change title)) (notify-track-change title))
(setf (ps:@ el text-content) title) (setf (ps:@ el text-content) title)
;; Check if this track is in user's favorites
(check-favorite-status-mini)) (check-favorite-status-mini))
(update-media-session title) (update-media-session title)
(when track-id-el (when track-id-el
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id)))) (let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
(setf (ps:@ track-id-el value) (or track-id "")))) (setf (ps:@ track-id-el value) (or track-id ""))))
;; Update favorite count display
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini"))) (let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini")))
(fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0))) (fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0)))
(when count-el (when count-el
@ -531,7 +561,10 @@
((= 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 ;; Sync countdown timer from server
(let ((remaining (or (ps:@ data data remaining) (ps:@ data remaining))))
(when remaining
(setf *track-remaining-seconds* remaining)))
(let ((mb-link (ps:chain document (get-element-by-id "mini-musicbrainz-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)))) (search-url (or (ps:@ data data search_url) (ps:@ data search_url))))
(when mb-link (when mb-link
@ -724,6 +757,35 @@
(catch (lambda (err) (catch (lambda (err)
(ps:chain console (log "Reconnect failed:" err)))))) (ps:chain console (log "Reconnect failed:" err))))))
;; Buffer bloat detection and reset
(defvar *max-buffer-seconds* 15)
(defvar *buffer-check-interval* nil)
(defun get-buffer-ahead (audio-element)
"Return seconds of audio buffered ahead of current playback position."
(ps:try
(when (and (ps:@ audio-element buffered)
(> (ps:@ audio-element buffered length) 0))
(- (ps:chain audio-element buffered (end (- (ps:@ audio-element buffered length) 1)))
(ps:@ audio-element current-time)))
(:catch (e) 0)))
(defun start-buffer-monitor (audio-element)
(when *buffer-check-interval*
(clear-interval *buffer-check-interval*))
(setf *buffer-check-interval*
(set-interval
(lambda ()
(when (and (not (ps:@ audio-element paused))
(not *is-reconnecting*))
(let ((ahead (get-buffer-ahead audio-element)))
(when (and ahead (> ahead 0))
(ps:chain console (log (+ "[BUFFER] " (ps:chain ahead (to-fixed 1)) "s ahead")))
(when (> ahead *max-buffer-seconds*)
(ps:chain console (log (+ "[BUFFER] Bloat detected (" (ps:chain ahead (to-fixed 1)) "s), resetting stream")))
(reconnect-stream))))))
10000)))
;; Attach event listeners to audio element ;; Attach event listeners to audio element
(defun attach-audio-listeners (audio-element) (defun attach-audio-listeners (audio-element)
(ps:chain audio-element (ps:chain audio-element
@ -907,8 +969,9 @@
:artist "Asteroid Radio" :artist "Asteroid Radio"
:album "Live Broadcast"))))) :album "Live Broadcast")))))
;; Attach event listeners ;; Attach event listeners and buffer monitor
(attach-audio-listeners audio-element) (attach-audio-listeners audio-element)
(start-buffer-monitor audio-element)
;; Restore user channel preference ;; Restore user channel preference
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel"))) (let ((channel-selector (ps:chain document (get-element-by-id "stream-channel")))
@ -958,9 +1021,10 @@
(ps:chain console (log "Could not fetch channel name:" error)))))) (ps:chain console (log "Could not fetch channel name:" error))))))
15000)) ;; Poll every 15 seconds 15000)) ;; Poll every 15 seconds
;; Start now playing updates ;; Start now playing updates and countdown ticker
(set-timeout update-mini-now-playing 1000) (set-timeout update-mini-now-playing 1000)
(set-interval update-mini-now-playing 15000)))) (set-interval update-mini-now-playing 15000)
(start-countdown-ticker))))
;; Initialize popout player ;; Initialize popout player
(defun init-popout-player () (defun init-popout-player ()

113
playlists/deep-focus.m3u Normal file
View File

@ -0,0 +1,113 @@
#EXTM3U
#PLAYLIST:Deep Focus
#PHASE:Deep Focus
#DURATION:5 hours (approx)
#CURATOR:Asteroid Radio
#DESCRIPTION:Upbeat and cheerful electronic music for long coding sessions - IDM, shoegaze electronica, melodic techno, and ambient rhythms
#EXTINF:-1,Tycho - Glider
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
#EXTINF:-1,Boards of Canada - Spectrum
/app/music/Boards of Canada/A Few Old Tunes/01 - Spectrum.mp3
#EXTINF:-1,Ulrich Schnauss - Melts into Air
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/01. Melts into Air (2019 Version).flac
#EXTINF:-1,Plaid - Do Matter
/app/music/Plaid - The Digging Remedy (2016) [FLAC]/01 - Do Matter.flac
#EXTINF:-1,Four Tet - Two Thousand And Seventeen
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/02 Two Thousand And Seventeen.flac
#EXTINF:-1,Kiasmos - Lit
/app/music/Kiasmos/2014 - Kiasmos/01 - Lit.flac
#EXTINF:-1,Proem - A Good Soaking
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/12 - A Good Soaking.flac
#EXTINF:-1,Cut Copy - Standing in the Middle of the Field
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/01 - Standing in the Middle of the Field.flac
#EXTINF:-1,Marconi Union - Sleeper
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/01. Marconi Union - Sleeper.flac
#EXTINF:-1,Clark - Peak Magnetic
/app/music/Clark - Death Peak (2017) [FLAC]/03 - Peak Magnetic.flac
#EXTINF:-1,Tycho - Weather
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/01 - Weather.flac
#EXTINF:-1,Boards of Canada - Happy Cycling
/app/music/Boards of Canada/A Few Old Tunes/06 - Happy Cycling.mp3
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Asteroid 2467
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/01. Asteroid 2467.flac
#EXTINF:-1,Faux Tales - Avalon
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
#EXTINF:-1,Vector Lovers - City Lights From A Train
/app/music/Vector Lovers/2005 - Capsule For One/01 - City Lights From A Train.mp3
#EXTINF:-1,Plaid - Maru
/app/music/Plaid - Polymer (2019) [WEB FLAC]/03 - Maru.flac
#EXTINF:-1,Four Tet - Baby
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/02 - Four Tet - Baby.flac
#EXTINF:-1,Bluetech - Laika
/app/music/Bluetech - Spacehop Chronicles Vol. 1 (flac)/01. Laika.flac
#EXTINF:-1,Kiasmos - Looped
/app/music/Kiasmos/2014 - Kiasmos/03 - Looped.flac
#EXTINF:-1,Proem - Snow Drifts
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac
#EXTINF:-1,Thievery Corporation - Fragments (Tycho Remix)
/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/2. Thievery Corporation - Fragments (Tycho Remix).flac
#EXTINF:-1,Ulrich Schnauss - Love Grows Out of Thin Air
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/02. Love Grows Out of Thin Air (2019 Version).flac
#EXTINF:-1,Clark - Kiri's Glee
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
#EXTINF:-1,Cut Copy - Airborne
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
#EXTINF:-1,Marconi Union - Riser
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/04. Marconi Union - Riser.flac
#EXTINF:-1,Boards of Canada - Forest Moon
/app/music/Boards of Canada/A Few Old Tunes/09 - Forest Moon.mp3
#EXTINF:-1,Tycho - Ascension
/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.flac
#EXTINF:-1,Plaid - Dancers
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac
#EXTINF:-1,Quaeschning & Ulrich Schnauss - A Calm but Steady Flow
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/05 - A Calm but Steady Flow.flac
#EXTINF:-1,Bitcrush - Engale
/app/music/Bitcrush - Enarc (flac)/01 - Engale.flac
#EXTINF:-1,Four Tet - Parallel 1
/app/music/Four Tet - Parallel (2020) - WEB FLAC/01. Parallel 1.flac
#EXTINF:-1,woob - INNA
/app/music/woob - Mass Distraction EP [WEB FLAC 24]/woob - Mass Distraction EP - 01 INNA.flac
#EXTINF:-1,Vector Lovers - Nostalgia 4 The Future
/app/music/Vector Lovers/2005 - Capsule For One/05 - Nostalgia 4 The Future.mp3
#EXTINF:-1,Kiasmos - Swayed
/app/music/Kiasmos/2014 - Kiasmos/04 - Swayed.flac
#EXTINF:-1,Proem - Keep This Whole
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/10 - Keep This Whole.flac
#EXTINF:-1,Ulrich Schnauss - Her and the Sea
/app/music/Ulrich Schnauss - A Long Way To Fall - Rebound (2020) - WEB FLAC/01. Her and the Sea.flac
#EXTINF:-1,Faux Tales - Oceania
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/4 - Oceania.flac
#EXTINF:-1,Cut Copy - Stars Last Me a Lifetime
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/04 - Stars Last Me a Lifetime.flac
#EXTINF:-1,Plaid - The Bee
/app/music/Plaid - The Digging Remedy (2016) [FLAC]/04 - The Bee.flac
#EXTINF:-1,Clark - Living Fantasy
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
#EXTINF:-1,Tycho - Japan (Satin Jackets Remix)
/app/music/Tycho - Weather Remixes (2020) - WEB FLAC/03. Japan (Satin Jackets Remix).flac
#EXTINF:-1,Boards of Canada - Skimming Stones
/app/music/Boards of Canada/A Few Old Tunes/10 - Skimming Stones.mp3
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Perpetual Motion
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/04. Perpetual Motion.flac
#EXTINF:-1,Bluetech - Skybox
/app/music/Bluetech - Spacehop Chronicles Vol. 1 (flac)/04. Skybox.flac
#EXTINF:-1,God is an Astronaut - Epitaph
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/01. Epitaph.flac
#EXTINF:-1,Seba & Ulrich Schnauss - M7
/app/music/Seba & Ulrich Schnauss - Snöflingor EP [2017] [WEB_FLAC]/01. Seba & Ulrich Schnauss - M7.flac
#EXTINF:-1,Quaeschning & Ulrich Schnauss - Prism
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/08 - Prism.flac
#EXTINF:-1,High Energy Protons - The Heavens (Monolith mix)
/app/music/High Energy Protons/03 - The Heavens (Monolith mix).mp3
#EXTINF:-1,woob - Subterranean District
/app/music/woob - Tokyo Run - Series 8 Keycard - Lv.6 [2017] WEB [FLAC24] [16-44]/Tokyo Run - 24 Bit Masters/06 woob - Subterranean District.flac
#EXTINF:-1,Bitcrush - Two Go From There
/app/music/Bitcrush - Enarc (flac)/03 - Two Go From There.flac
#EXTINF:-1,Daft Punk - Derezzed
/app/music/Daft Punk - TRON Legacy - The Complete Edition (2020 - WEB - FLAC)/13 Derezzed.flac
#EXTINF:-1,God is an Astronaut - Komorebi
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/05. Komorebi.flac
#EXTINF:-1,Daft Punk - The Son of Flynn
/app/music/Daft Punk - TRON Legacy - The Complete Edition (2020 - WEB - FLAC)/03 The Son of Flynn.flac

View File

@ -1372,6 +1372,14 @@ body.persistent-player-container .now-playing-mini{
min-width: 300px; min-width: 300px;
} }
body.persistent-player-container .track-countdown-mini{
color: #888;
font-size: 0.75em;
font-family: monospace;
margin-left: 6px;
white-space: nowrap;
}
body.persistent-player-container .persistent-reconnect-btn{ body.persistent-player-container .persistent-reconnect-btn{
background: transparent; background: transparent;
color: #00ff00; color: #00ff00;
@ -1595,6 +1603,13 @@ body.popout-body .status-mini{
margin: 0 5px; margin: 0 5px;
} }
.track-countdown{
color: #888;
font-size: 0.8em;
font-family: monospace;
margin-left: 8px;
}
.now-playing-track{ .now-playing-track{
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1108,6 +1108,13 @@
:flex 1 :flex 1
:min-width "300px") :min-width "300px")
(.track-countdown-mini
:color "#888"
:font-size "0.75em"
:font-family "monospace"
:margin-left "6px"
:white-space "nowrap")
(.persistent-reconnect-btn (.persistent-reconnect-btn
:background transparent :background transparent
:color "#00ff00" :color "#00ff00"
@ -1288,6 +1295,12 @@
(.craftering (.craftering
(a :margin "0 5px"))) (a :margin "0 5px")))
(.track-countdown
:color "#888"
:font-size "0.8em"
:font-family "monospace"
:margin-left "8px")
;; Now playing favorite button ;; Now playing favorite button
(.now-playing-track (.now-playing-track
:display "flex" :display "flex"

View File

@ -148,6 +148,11 @@
(setf *current-playlist-path* playlist-path) (setf *current-playlist-path* playlist-path)
(log:info "Playlist now active: ~A" (file-namestring playlist-path))) (log:info "Playlist now active: ~A" (file-namestring playlist-path)))
(defvar *pending-save-file* nil
"The file path that will be saved on the NEXT track change.
This one-track delay ensures we persist the track that was actually playing,
not the one being loaded during crossfade.")
(defun on-harmony-track-change (pipeline track-info) (defun on-harmony-track-change (pipeline track-info)
"Called by cl-streamer when a track changes. "Called by cl-streamer when a track changes.
Updates recently-played lists and finds the track in the database." Updates recently-played lists and finds the track in the database."
@ -168,9 +173,10 @@
:track-id track-id) :track-id track-id)
:curated) :curated)
(setf *last-known-track-curated* display-title)) (setf *last-known-track-curated* display-title))
;; Persist current track for resume-on-restart ;; Save the PREVIOUS track (which was actually playing) and queue this one
(when file-path (when *pending-save-file*
(save-playback-state file-path)) (save-playback-state *pending-save-file*))
(setf *pending-save-file* file-path)
(log:info "Track change: ~A (track-id: ~A)" display-title track-id))) (log:info "Track change: ~A (track-id: ~A)" display-title track-id)))
(defun find-track-by-file-path (file-path) (defun find-track-by-file-path (file-path)
@ -191,19 +197,38 @@
(defun harmony-now-playing (&optional (mount "asteroid.mp3")) (defun harmony-now-playing (&optional (mount "asteroid.mp3"))
"Get now-playing information from cl-streamer pipeline. "Get now-playing information from cl-streamer pipeline.
Returns an alist with now-playing data, or NIL if the pipeline is not running." Uses the metadata timeline to report what listeners are actually hearing,
accounting for ring buffer and browser decode buffering."
(when (and *harmony-pipeline* (when (and *harmony-pipeline*
(cl-streamer/harmony:pipeline-current-track *harmony-pipeline*)) (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(let* ((track-info (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*)) (let* ((server (cl-streamer/harmony:pipeline-server *harmony-pipeline*))
(display-title (or (getf track-info :display-title) "Unknown")) (listener-title (when server
(cl-streamer:get-listener-now-playing
server (format nil "/~A" mount))))
(track-info (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(display-title (or listener-title
(getf track-info :display-title)
"Unknown"))
(listeners (cl-streamer:pipeline-listener-count *harmony-pipeline*)) (listeners (cl-streamer:pipeline-listener-count *harmony-pipeline*))
(track-id (or (find-track-by-title display-title) (track-id (or (find-track-by-title display-title)
(find-track-by-file-path (getf track-info :file))))) (find-track-by-file-path (getf track-info :file))))
(raw-remaining (cl-streamer/harmony:pipeline-track-remaining *harmony-pipeline*))
;; Adjust for browser buffer delay - listener is further behind than pipeline
(remaining (when raw-remaining
(let ((adjusted (+ raw-remaining cl-streamer::*browser-buffer-seconds*)))
(max 0 (floor adjusted))))))
;; Diagnostic: log when listener-title differs from pipeline title
(let ((pipeline-title (getf track-info :display-title)))
(when (and listener-title pipeline-title
(not (string= listener-title pipeline-title)))
(log:info "[SYNC-DIAG] API returning ~S (pipeline has ~S, delay=~As)"
listener-title pipeline-title cl-streamer::*browser-buffer-seconds*)))
`((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount)) `((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount))
(:title . ,display-title) (:title . ,display-title)
(:listeners . ,(or listeners 0)) (:listeners . ,(or listeners 0))
(:track-id . ,track-id) (:track-id . ,track-id)
(:favorite-count . ,(or (get-track-favorite-count display-title) 0)))))) (:favorite-count . ,(or (get-track-favorite-count display-title) 0))
,@(when remaining `((:remaining . ,remaining)))))))
;;; ---- Pipeline Lifecycle ---- ;;; ---- Pipeline Lifecycle ----

View File

@ -39,6 +39,7 @@
<a id="mini-musicbrainz-link" href="#" target="_blank" title="Search on MusicBrainz" style="display: none; margin-right: 4px; text-decoration: none;">🔗</a> <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 id="track-countdown" class="track-countdown-mini"></span>
<span class="favorite-count-mini" id="favorite-count-mini"></span> <span class="favorite-count-mini" id="favorite-count-mini"></span>
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites"> <button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">

View File

@ -3,7 +3,8 @@
<c:then> <c:then>
<c:using value="stats"> <c:using value="stats">
<div class="now-playing-track"> <div class="now-playing-track">
<p>Track: <span lquery="(text title)" id="current-track-title">The Void - Silence</span></p> <p>Track: <span lquery="(text title)" id="current-track-title">The Void - Silence</span>
<span id="track-countdown-main" class="track-countdown"></span></p>
<button class="btn-favorite" id="favorite-btn" onclick="toggleFavorite()" title="Add to favorites"> <button class="btn-favorite" id="favorite-btn" onclick="toggleFavorite()" title="Add to favorites">
<span class="star-icon">☆</span> <span class="star-icon">☆</span>
</button> </button>