diff --git a/asteroid.lisp b/asteroid.lisp index cb274af..2deb56b 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -24,6 +24,13 @@ (defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) (defparameter *stream-base-url* "http://localhost:8000") +;; Recently played tracks storage (in-memory) +(defparameter *recently-played* nil + "List of recently played tracks (max 3), newest first") +(defparameter *recently-played-lock* (bt:make-lock "recently-played-lock")) +(defparameter *last-known-track* nil + "Last known track title to detect changes") + ;; Configure JSON as the default API format (define-api-format json (data) "JSON API format for Radiance" @@ -33,6 +40,40 @@ ;; Set JSON as the default API format (setf *default-api-format* "json") +;; Recently played tracks management +(defun add-recently-played (track-info) + "Add a track to the recently played list (max 3 tracks)" + (bt:with-lock-held (*recently-played-lock*) + (push track-info *recently-played*) + (when (> (length *recently-played*) 3) + (setf *recently-played* (subseq *recently-played* 0 3))))) + +(defun universal-time-to-unix (universal-time) + "Convert Common Lisp universal time to Unix timestamp" + ;; Universal time is seconds since 1900-01-01, Unix is since 1970-01-01 + ;; Difference is 2208988800 seconds (70 years) + (- universal-time 2208988800)) + +(defun get-recently-played () + "Get the list of recently played tracks" + (bt:with-lock-held (*recently-played-lock*) + (copy-list *recently-played*))) + +(defun parse-track-title (title) + "Parse track title into artist and song name. Expected format: 'Artist - Song'" + (let ((pos (search " - " title))) + (if pos + (list :artist (string-trim " " (subseq title 0 pos)) + :song (string-trim " " (subseq title (+ pos 3)))) + (list :artist "Unknown" :song title)))) + +(defun generate-music-search-url (artist song) + "Generate MusicBrainz search URL for artist and song" + ;; MusicBrainz uses 'artist:' and 'recording:' prefixes for better search + (let ((query (format nil "artist:\"~a\" recording:\"~a\"" artist song))) + (format nil "https://musicbrainz.org/search?query=~a&type=recording&method=indexed" + (drakma:url-encode query :utf-8)))) + ;; API Routes using Radiance's define-api ;; API endpoints are accessed at /api/ automatically ;; They use lambda-lists for parameters and api-output for responses @@ -135,6 +176,27 @@ ("message" . "Playlist not found")) :status 404))))) +;; Recently played tracks API endpoint +(define-api asteroid/recently-played () () + "Get the last 3 played tracks with AllMusic links" + (with-error-handling + (let ((tracks (get-recently-played))) + (api-output `(("status" . "success") + ("tracks" . ,(mapcar (lambda (track) + (let* ((title (getf track :title)) + (timestamp (getf track :timestamp)) + (unix-timestamp (universal-time-to-unix timestamp)) + (parsed (parse-track-title title)) + (artist (getf parsed :artist)) + (song (getf parsed :song)) + (search-url (generate-music-search-url artist song))) + `(("title" . ,title) + ("artist" . ,artist) + ("song" . ,song) + ("timestamp" . ,unix-timestamp) + ("search_url" . ,search-url)))) + tracks))))))) + ;; API endpoint to get all tracks (for web player) (define-api asteroid/tracks () () "Get all tracks for web player" diff --git a/frontend-partials.lisp b/frontend-partials.lisp index cc6f976..383b212 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -33,6 +33,13 @@ (aref groups 0) "Unknown"))) "Unknown"))) + ;; Track recently played if title changed + (when (and title + (not (string= title "Unknown")) + (not (equal title *last-known-track*))) + (setf *last-known-track* title) + (add-recently-played (list :title title + :timestamp (get-universal-time)))) `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) (:title . ,title) (:listeners . ,total-listeners))))))) diff --git a/static/asteroid.css b/static/asteroid.css index 1f03f15..5d3e1a7 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -143,6 +143,118 @@ body .now-playing{ overflow: auto; } +body .recently-played-panel{ + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} + +body .recently-played-panel h3{ + margin: 0 0 15px 0; + color: #00ff00; + font-size: 1.2em; + font-weight: 600; +} + +body .recently-played-panel .recently-played-list{ + min-height: 100px; +} + +body .recently-played-panel .recently-played-list .loading, +body .recently-played-panel .recently-played-list .no-tracks, +body .recently-played-panel .recently-played-list .error{ + text-align: center; + color: #888; + padding: 20px; + font-style: italic; +} + +body .recently-played-panel .recently-played-list .error{ + color: #ff4444; +} + +body .recently-played-panel .recently-played-list.track-list{ + list-style: none; + padding: 0; + margin: 0; + border: none; + max-height: none; + overflow-y: visible; +} + +body .recently-played-panel .recently-played-list.track-item{ + padding: 12px; + border-bottom: 1px solid #2a3441; + -moz-transition: background-color 0.2s; + -o-transition: background-color 0.2s; + -webkit-transition: background-color 0.2s; + -ms-transition: background-color 0.2s; + transition: background-color 0.2s; +} + +body .recently-played-panel .recently-played-list.track-item LAST-CHILD{ + border-bottom: none; +} + +body .recently-played-panel .recently-played-list.track-item HOVER{ + background-color: #252525; +} + +body .recently-played-panel .recently-played-list.track-info{ + display: flex; + flex-direction: column; + gap: 4px; +} + +body .recently-played-panel .recently-played-list.track-title{ + color: #fff; + font-weight: 500; + font-size: 1em; +} + +body .recently-played-panel .recently-played-list.track-artist{ + color: #aaa; + font-size: 0.9em; +} + +body .recently-played-panel .recently-played-list.track-meta{ + display: flex; + align-items: center; + gap: 12px; + margin-top: 4px; +} + +body .recently-played-panel .recently-played-list.track-time{ + color: #666; + font-size: 0.85em; +} + +body .recently-played-panel .recently-played-list.allmusic-link{ + display: inline-flex; + align-items: center; + gap: 4px; + color: #00ff00; + text-decoration: none; + font-size: 0.85em; + -moz-transition: color 0.2s; + -o-transition: color 0.2s; + -webkit-transition: color 0.2s; + -ms-transition: color 0.2s; + transition: color 0.2s; +} + +body .recently-played-panel .recently-played-list.allmusic-link HOVER{ + color: #00cc00; + text-decoration: underline; +} + +body .recently-played-panel .recently-played-list.allmusic-link svg{ + width: 14px; + height: 14px; +} + body .back{ color: #00ffff; text-decoration: none; diff --git a/static/asteroid.lass b/static/asteroid.lass index f828d0f..da90b0c 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -123,6 +123,86 @@ :color "#4488ff" :overflow auto) + ;; Recently Played Panel + (.recently-played-panel + :background "#1a2332" + :border "1px solid #2a3441" + :border-radius "8px" + :padding "20px" + :margin "20px 0" + + (h3 :margin "0 0 15px 0" + :color "#00ff00" + :font-size "1.2em" + :font-weight 600) + + (.recently-played-list + :min-height "100px" + + ((:or .loading .no-tracks .error) + :text-align center + :color "#888" + :padding "20px" + :font-style italic) + + (.error :color "#ff4444")) + + ((:and .recently-played-list .track-list) + :list-style none + :padding 0 + :margin 0 + :border none + :max-height none + :overflow-y visible) + + ((:and .recently-played-list .track-item) + :padding "12px" + :border-bottom "1px solid #2a3441" + :transition "background-color 0.2s" + + ((:and :last-child) :border-bottom none) + ((:and :hover) :background-color "#252525")) + + ((:and .recently-played-list .track-info) + :display flex + :flex-direction column + :gap "4px") + + ((:and .recently-played-list .track-title) + :color "#fff" + :font-weight 500 + :font-size "1em") + + ((:and .recently-played-list .track-artist) + :color "#aaa" + :font-size "0.9em") + + ((:and .recently-played-list .track-meta) + :display flex + :align-items center + :gap "12px" + :margin-top "4px") + + ((:and .recently-played-list .track-time) + :color "#666" + :font-size "0.85em") + + ((:and .recently-played-list .allmusic-link) + :display inline-flex + :align-items center + :gap "4px" + :color "#00ff00" + :text-decoration none + :font-size "0.85em" + :transition "color 0.2s" + + ((:and :hover) + :color "#00cc00" + :text-decoration underline) + + (svg :width "14px" + :height "14px"))) + (.back :color "#00ffff" :text-decoration none diff --git a/static/js/recently-played.js b/static/js/recently-played.js new file mode 100644 index 0000000..e3d7e03 --- /dev/null +++ b/static/js/recently-played.js @@ -0,0 +1,86 @@ +// Recently Played Tracks functionality + +async function updateRecentlyPlayed() { + try { + const response = await fetch('/api/asteroid/recently-played'); + const result = await response.json(); + + // Radiance wraps API responses in a data envelope + const data = result.data || result; + + if (data.status === 'success' && data.tracks && data.tracks.length > 0) { + const listEl = document.getElementById('recently-played-list'); + if (!listEl) return; + + // Build HTML for tracks + let html = ''; + listEl.innerHTML = html; + } else { + const listEl = document.getElementById('recently-played-list'); + if (listEl) { + listEl.innerHTML = '

No tracks played yet

'; + } + } + } catch (error) { + console.error('Error fetching recently played:', error); + const listEl = document.getElementById('recently-played-list'); + if (listEl) { + listEl.innerHTML = '

Error loading recently played tracks

'; + } + } +} + +function formatTimeAgo(timestamp) { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + const panel = document.getElementById('recently-played-panel'); + if (panel) { + updateRecentlyPlayed(); + // Update every 30 seconds + setInterval(updateRecentlyPlayed, 30000); + } else { + const list = document.getElementById('recently-played-list'); + if (list) { + updateRecentlyPlayed(); + setInterval(updateRecentlyPlayed, 30000); + } + } +}); diff --git a/template/front-page-content.ctml b/template/front-page-content.ctml index 8018f0f..39ef292 100644 --- a/template/front-page-content.ctml +++ b/template/front-page-content.ctml @@ -7,6 +7,7 @@ +
@@ -35,6 +36,14 @@
+ + +
+

Recently Played

+
+

Loading...

+
+
diff --git a/template/front-page.ctml b/template/front-page.ctml index 7a3f0c8..f164a07 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -7,6 +7,7 @@ +
@@ -61,6 +62,14 @@
+ + +
+

Recently Played

+
+

Loading...

+
+