diff --git a/static/asteroid.css b/static/asteroid.css
index 3453b09..08875d9 100644
--- a/static/asteroid.css
+++ b/static/asteroid.css
@@ -431,6 +431,89 @@ body .queue-item:last-child{
margin-bottom: 0;
}
+body .queue-position{
+ background: #00ff00;
+ color: #000;
+ padding: 4px 8px;
+ border-radius: 3px;
+ font-weight: bold;
+ margin-right: 10px;
+ min-width: 30px;
+ text-align: center;
+ display: inline-block;
+}
+
+body .queue-track-info{
+ flex: 1;
+ margin-right: 10px;
+}
+
+body .queue-track-info.track-title{
+ font-weight: bold;
+ margin-bottom: 2px;
+}
+
+body .queue-track-info.track-artist{
+ font-size: 0.9em;
+ color: #888;
+}
+
+body .queue-actions{
+ margin-top: 20px;
+ padding: 15px;
+ background: #0a0a0a;
+ border: 1px solid #2a3441;
+ border-radius: 4px;
+}
+
+body .queue-list{
+ border: 1px solid #2a3441;
+ background: #0a0a0a;
+ min-height: 200px;
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 10px;
+ margin-bottom: 20px;
+}
+
+body .search-results{
+ margin-top: 10px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+body .search-result-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px;
+ border: 1px solid #2a3441;
+ margin-bottom: 5px;
+ background: #0a0a0a;
+ border-radius: 3px;
+}
+
+body .search-result-item:hover{
+ background: #1a1a1a;
+ border-color: #00ff00;
+}
+
+body .search-result-item.track-info{
+ flex: 1;
+}
+
+body .search-result-item.track-actions{
+ display: flex;
+ gap: 5px;
+}
+
+body .empty-state{
+ text-align: center;
+ color: #666;
+ padding: 30px;
+ font-style: italic;
+}
+
body .empty-queue{
text-align: center;
color: #666;
diff --git a/static/asteroid.lass b/static/asteroid.lass
index b95102d..f71752f 100644
--- a/static/asteroid.lass
+++ b/static/asteroid.lass
@@ -347,6 +347,77 @@
:border-bottom none
:margin-bottom 0)
+ (.queue-position
+ :background "#00ff00"
+ :color "#000"
+ :padding "4px 8px"
+ :border-radius "3px"
+ :font-weight bold
+ :margin-right "10px"
+ :min-width "30px"
+ :text-align center
+ :display inline-block)
+
+ (.queue-track-info
+ :flex 1
+ :margin-right "10px")
+
+ ((:and .queue-track-info .track-title)
+ :font-weight bold
+ :margin-bottom "2px")
+
+ ((:and .queue-track-info .track-artist)
+ :font-size "0.9em"
+ :color "#888")
+
+ (.queue-actions
+ :margin-top "20px"
+ :padding "15px"
+ :background "#0a0a0a"
+ :border "1px solid #2a3441"
+ :border-radius "4px")
+
+ (.queue-list
+ :border "1px solid #2a3441"
+ :background "#0a0a0a"
+ :min-height "200px"
+ :max-height "400px"
+ :overflow-y auto
+ :padding "10px"
+ :margin-bottom "20px")
+
+ (.search-results
+ :margin-top "10px"
+ :max-height "300px"
+ :overflow-y auto)
+
+ (.search-result-item
+ :display flex
+ :justify-content space-between
+ :align-items center
+ :padding "10px"
+ :border "1px solid #2a3441"
+ :margin-bottom "5px"
+ :background "#0a0a0a"
+ :border-radius "3px")
+
+ ((:and .search-result-item :hover)
+ :background "#1a1a1a"
+ :border-color "#00ff00")
+
+ ((:and .search-result-item .track-info)
+ :flex 1)
+
+ ((:and .search-result-item .track-actions)
+ :display flex
+ :gap "5px")
+
+ (.empty-state
+ :text-align center
+ :color "#666"
+ :padding "30px"
+ :font-style italic)
+
(.empty-queue
:text-align center
:color "#666"
diff --git a/static/js/admin.js b/static/js/admin.js
index ba5548d..27cfb00 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -25,6 +25,30 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('player-pause').addEventListener('click', pausePlayer);
document.getElementById('player-stop').addEventListener('click', stopPlayer);
document.getElementById('player-resume').addEventListener('click', resumePlayer);
+
+ // Queue controls
+ const refreshQueueBtn = document.getElementById('refresh-queue');
+ const clearQueueBtn = document.getElementById('clear-queue-btn');
+ const addRandomBtn = document.getElementById('add-random-tracks');
+ const queueSearchInput = document.getElementById('queue-track-search');
+
+ if (refreshQueueBtn) refreshQueueBtn.addEventListener('click', loadStreamQueue);
+ if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue);
+ if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks);
+ if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue);
+
+ // Load initial queue
+ loadStreamQueue();
+
+ // Setup live stream monitor
+ const liveAudio = document.getElementById('live-stream-audio');
+ if (liveAudio) {
+ liveAudio.preload = 'none';
+ }
+
+ // Update live stream info
+ updateLiveStreamInfo();
+ setInterval(updateLiveStreamInfo, 10000); // Every 10 seconds
});
// Load tracks from API
@@ -81,6 +105,7 @@ function renderPage() {
+
@@ -304,3 +329,267 @@ function openIncomingFolder() {
// Update player status every 5 seconds
setInterval(updatePlayerStatus, 5000);
+
+// ========================================
+// Stream Queue Management
+// ========================================
+
+let streamQueue = [];
+let queueSearchTimeout = null;
+
+// Load current stream queue
+async function loadStreamQueue() {
+ try {
+ const response = await fetch('/api/asteroid/stream/queue');
+ const result = await response.json();
+ const data = result.data || result;
+
+ if (data.status === 'success') {
+ streamQueue = data.queue || [];
+ displayStreamQueue();
+ }
+ } catch (error) {
+ console.error('Error loading stream queue:', error);
+ document.getElementById('stream-queue-container').innerHTML =
+ 'Error loading queue
';
+ }
+}
+
+// Display stream queue
+function displayStreamQueue() {
+ const container = document.getElementById('stream-queue-container');
+
+ if (streamQueue.length === 0) {
+ container.innerHTML = 'Queue is empty. Add tracks below.
';
+ return;
+ }
+
+ let html = '';
+ streamQueue.forEach((item, index) => {
+ if (item) {
+ html += `
+
+
${index + 1}
+
+
${item.title || 'Unknown'}
+
${item.artist || 'Unknown Artist'}
+
+
+
+ `;
+ }
+ });
+ html += '
';
+
+ container.innerHTML = html;
+}
+
+// Clear stream queue
+async function clearStreamQueue() {
+ if (!confirm('Clear the entire stream queue? This will stop playback until new tracks are added.')) {
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/asteroid/stream/queue/clear', {
+ method: 'POST'
+ });
+ const result = await response.json();
+ const data = result.data || result;
+
+ if (data.status === 'success') {
+ alert('Queue cleared successfully');
+ loadStreamQueue();
+ } else {
+ alert('Error clearing queue: ' + (data.message || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error clearing queue:', error);
+ alert('Error clearing queue');
+ }
+}
+
+// Remove track from queue
+async function removeFromQueue(trackId) {
+ try {
+ const response = await fetch('/api/asteroid/stream/queue/remove', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: `track-id=${trackId}`
+ });
+ const result = await response.json();
+ const data = result.data || result;
+
+ if (data.status === 'success') {
+ loadStreamQueue();
+ } else {
+ alert('Error removing track: ' + (data.message || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error removing track:', error);
+ alert('Error removing track');
+ }
+}
+
+// Add track to queue
+async function addToQueue(trackId, position = 'end', showNotification = true) {
+ try {
+ const response = await fetch('/api/asteroid/stream/queue/add', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: `track-id=${trackId}&position=${position}`
+ });
+ const result = await response.json();
+ const data = result.data || result;
+
+ if (data.status === 'success') {
+ // Only reload queue if we're in the queue management section
+ const queueContainer = document.getElementById('stream-queue-container');
+ if (queueContainer && queueContainer.offsetParent !== null) {
+ loadStreamQueue();
+ }
+
+ // Show brief success notification
+ if (showNotification) {
+ showToast('✓ Added to queue');
+ }
+ return true;
+ } else {
+ alert('Error adding track: ' + (data.message || 'Unknown error'));
+ return false;
+ }
+ } catch (error) {
+ console.error('Error adding track:', error);
+ alert('Error adding track');
+ return false;
+ }
+}
+
+// Simple toast notification
+function showToast(message) {
+ const toast = document.createElement('div');
+ toast.textContent = message;
+ toast.style.cssText = `
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ background: #00ff00;
+ color: #000;
+ padding: 12px 20px;
+ border-radius: 4px;
+ font-weight: bold;
+ z-index: 10000;
+ animation: slideIn 0.3s ease-out;
+ `;
+ document.body.appendChild(toast);
+
+ setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transition = 'opacity 0.3s';
+ setTimeout(() => toast.remove(), 300);
+ }, 2000);
+}
+
+// Add random tracks to queue
+async function addRandomTracks() {
+ if (tracks.length === 0) {
+ alert('No tracks available. Please scan the library first.');
+ return;
+ }
+
+ const count = 10;
+ const shuffled = [...tracks].sort(() => Math.random() - 0.5);
+ const selected = shuffled.slice(0, Math.min(count, tracks.length));
+
+ for (const track of selected) {
+ await addToQueue(track.id, 'end', false); // Don't show toast for each track
+ }
+
+ showToast(`✓ Added ${selected.length} random tracks to queue`);
+}
+
+// Search tracks for adding to queue
+function searchTracksForQueue(event) {
+ clearTimeout(queueSearchTimeout);
+ const query = event.target.value.toLowerCase();
+
+ if (query.length < 2) {
+ document.getElementById('queue-track-results').innerHTML = '';
+ return;
+ }
+
+ queueSearchTimeout = setTimeout(() => {
+ const results = tracks.filter(track =>
+ (track.title && track.title.toLowerCase().includes(query)) ||
+ (track.artist && track.artist.toLowerCase().includes(query)) ||
+ (track.album && track.album.toLowerCase().includes(query))
+ ).slice(0, 20); // Limit to 20 results
+
+ displayQueueSearchResults(results);
+ }, 300);
+}
+
+// Display search results for queue
+function displayQueueSearchResults(results) {
+ const container = document.getElementById('queue-track-results');
+
+ if (results.length === 0) {
+ container.innerHTML = 'No tracks found
';
+ return;
+ }
+
+ let html = '';
+ results.forEach(track => {
+ html += `
+
+
+
${track.title || 'Unknown'}
+
${track.artist || 'Unknown'} - ${track.album || 'Unknown Album'}
+
+
+
+
+
+
+ `;
+ });
+ html += '
';
+
+ container.innerHTML = html;
+}
+
+// Live stream info update
+async function updateLiveStreamInfo() {
+ try {
+ const response = await fetch('/api/asteroid/icecast-status');
+ if (!response.ok) {
+ return;
+ }
+
+ const result = await response.json();
+
+ // Handle Radiance API response format
+ const data = result.data || result;
+
+ // Sources are nested in icestats
+ const sources = data.icestats?.source;
+
+ if (sources) {
+ const mainStream = Array.isArray(sources)
+ ? sources.find(s => s.listenurl?.includes('/asteroid.aac') || s.listenurl?.includes('/asteroid.mp3'))
+ : sources;
+
+ if (mainStream && mainStream.title) {
+ const nowPlayingEl = document.getElementById('live-now-playing');
+ if (nowPlayingEl) {
+ const parts = mainStream.title.split(' - ');
+ const artist = parts[0] || 'Unknown';
+ const track = parts.slice(1).join(' - ') || 'Unknown';
+ nowPlayingEl.textContent = `${artist} - ${track}`;
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Could not fetch stream info:', error);
+ }
+}
diff --git a/template/admin.chtml b/template/admin.chtml
index f903d11..0397ba9 100644
--- a/template/admin.chtml
+++ b/template/admin.chtml
@@ -107,6 +107,41 @@
+
+
+
📻 Live Stream Monitor
+
+
+
Now Playing: Loading...
+
+
+
+
+
+
+
🎵 Stream Queue Management
+
Manage the live stream playback queue. Changes take effect within 5-10 seconds.
+
+
+
+
+
+
+
+
+
+
+
Add Tracks to Queue
+
+
+
+
+
Player Control