diff --git a/asteroid.lisp b/asteroid.lisp index cb274af..027305e 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" + ;; Simple search without field prefixes works better with URL encoding + (let ((query (format nil "~a ~a" artist song))) + (format nil "https://musicbrainz.org/search?query=~a&type=recording" + (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..3518c32 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -143,6 +143,131 @@ 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: 6px 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: #2a3441; +} + +body .recently-played-panel .recently-played-list.track-info{ + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 2px 20px; + align-items: center; +} + +body .recently-played-panel .recently-played-list.track-title{ + color: #4488ff; + font-weight: 500; + font-size: 1em; + grid-column: 1; + grid-row: 1; +} + +body .recently-played-panel .recently-played-list.track-artist{ + color: #4488ff; + font-size: 0.9em; + grid-column: 1; + grid-row: 2; +} + +body .recently-played-panel .recently-played-list.track-time{ + color: #888; + font-size: 0.85em; + grid-column: 2; + grid-row: 1; + text-align: right; +} + +body .recently-played-panel .recently-played-list.track-meta{ + grid-column: 2; + grid-row: 2; + text-align: right; +} + + + +body .recently-played-list .allmusic-link{ + display: inline-flex; + align-items: center; + gap: 4px; + color: #4488ff; + text-decoration: none; + font-size: 0.85em; + letter-spacing: 0.08rem; + -moz-transition: all 0.2s; + -o-transition: all 0.2s; + -webkit-transition: all 0.2s; + -ms-transition: all 0.2s; + transition: all 0.2s; + white-space: nowrap; +} + +body .recently-played-list .allmusic-link HOVER{ + color: #00ff00; + text-decoration: underline; + text-underline-offset: 5px; +} + +body .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..0132ffc 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -123,6 +123,98 @@ :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 "6px 12px" + :border-bottom "1px solid #2a3441" + :transition "background-color 0.2s" + + ((:and :last-child) :border-bottom none) + ((:and :hover) :background-color "#2a3441")) + + ((:and .recently-played-list .track-info) + :display grid + :grid-template-columns "1fr auto" + :grid-template-rows "auto auto" + :gap "2px 20px" + :align-items center) + + ((:and .recently-played-list .track-title) + :color "#4488ff" + :font-weight 500 + :font-size "1em" + :grid-column 1 + :grid-row 1) + + ((:and .recently-played-list .track-artist) + :color "#4488ff" + :font-size "0.9em" + :grid-column 1 + :grid-row 2) + + ((:and .recently-played-list .track-time) + :color "#888" + :font-size "0.85em" + :grid-column 2 + :grid-row 1 + :text-align right) + + ((:and .recently-played-list .track-meta) + :grid-column 2 + :grid-row 2 + :text-align right)) + + (.recently-played-list + (.allmusic-link + :display inline-flex + :align-items center + :gap "4px" + :color "#4488ff" + :text-decoration none + :font-size "0.85em" + :letter-spacing "0.08rem" + :transition "all 0.2s" + :white-space nowrap + + ((:and :hover) + :color "#00ff00" + :text-decoration underline + :text-underline-offset "5px") + + (svg :width "14px" + :height "14px"))) + (.back :color "#00ffff" :text-decoration none diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..9da2059 Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..650b637 Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..1c1006b Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/js/recently-played.js b/static/js/recently-played.js new file mode 100644 index 0000000..db79f1c --- /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..b8912dd 100644 --- a/template/front-page-content.ctml +++ b/template/front-page-content.ctml @@ -4,14 +4,22 @@ 🎵 ASTEROID RADIO 🎵 + + + +
-

🎵 ASTEROID RADIO 🎵

+

+ Asteroid + ASTEROID RADIO + Asteroid +

+ + +
+

Recently Played

+
+

Loading...

+
+
diff --git a/template/front-page.ctml b/template/front-page.ctml index 7a3f0c8..8ea8e39 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -4,14 +4,22 @@ 🎵 ASTEROID RADIO 🎵 + + + +
-

🎵 ASTEROID RADIO 🎵

+

+ Asteroid + ASTEROID RADIO + Asteroid +

+ + +
+

Recently Played

+
+

Loading...

+
+
diff --git a/template/partial/recently-played.ctml b/template/partial/recently-played.ctml new file mode 100644 index 0000000..0076f1d --- /dev/null +++ b/template/partial/recently-played.ctml @@ -0,0 +1,6 @@ +
+

Recently Played

+
+

Loading...

+
+