Add admin UI for stream queue management
- Queue management section with add/remove/clear controls - Add to Queue button on each track in library browser - Search tracks and add to queue - Add 10 random tracks button - Live stream monitor with Now Playing display - Toast notifications for user feedback - Real-time queue updates
This commit is contained in:
parent
b64d101f8a
commit
d4edb8bfec
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div class="track-actions">
|
||||
<button onclick="playTrack(${track.id})" class="btn btn-sm btn-success">▶️ Play</button>
|
||||
<button onclick="streamTrack(${track.id})" class="btn btn-sm btn-info">🎵 Stream</button>
|
||||
<button onclick="addToQueue(${track.id}, 'end')" class="btn btn-sm btn-primary">➕ Add to Queue</button>
|
||||
<button onclick="deleteTrack(${track.id})" class="btn btn-sm btn-danger">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 =
|
||||
'<div class="error">Error loading queue</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display stream queue
|
||||
function displayStreamQueue() {
|
||||
const container = document.getElementById('stream-queue-container');
|
||||
|
||||
if (streamQueue.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Queue is empty. Add tracks below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="queue-items">';
|
||||
streamQueue.forEach((item, index) => {
|
||||
if (item) {
|
||||
html += `
|
||||
<div class="queue-item" data-track-id="${item.id}">
|
||||
<span class="queue-position">${index + 1}</span>
|
||||
<div class="queue-track-info">
|
||||
<div class="track-title">${item.title || 'Unknown'}</div>
|
||||
<div class="track-artist">${item.artist || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
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 = '<div class="empty-state">No tracks found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="search-results">';
|
||||
results.forEach(track => {
|
||||
html += `
|
||||
<div class="search-result-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown'}</div>
|
||||
<div class="track-artist">${track.artist || 'Unknown'} - ${track.album || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="addToQueue(${track.id}, 'end')">Add to End</button>
|
||||
<button class="btn btn-sm btn-success" onclick="addToQueue(${track.id}, 'next')">Play Next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,41 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Monitor -->
|
||||
<div class="admin-section">
|
||||
<h2>📻 Live Stream Monitor</h2>
|
||||
<div class="live-stream-monitor">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
|
||||
<audio id="live-stream-audio" controls style="width: 100%; max-width: 600px;">
|
||||
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Queue Management -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Stream Queue Management</h2>
|
||||
<p>Manage the live stream playback queue. Changes take effect within 5-10 seconds.</p>
|
||||
|
||||
<div class="queue-controls">
|
||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
||||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
|
||||
</div>
|
||||
|
||||
<div id="stream-queue-container" class="queue-list">
|
||||
<div class="loading">Loading queue...</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-actions">
|
||||
<h3>Add Tracks to Queue</h3>
|
||||
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
|
||||
<div id="queue-track-results" class="track-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Control -->
|
||||
<div class="admin-section">
|
||||
<h2>Player Control</h2>
|
||||
|
|
|
|||
Loading…
Reference in New Issue