feat: Add pop-out player and queue management improvements
- Add pop-out player window (400x300px) with auto-reconnect on stream errors - Add queue reordering with up/down buttons in admin panel - Add 'Load Queue from M3U' functionality - Remove Play/Stream buttons from track management - Fix Liquidsoap audio quality issues: - Remove ReplayGain and compression to prevent pulsing - Change reload_mode to 'seconds' to prevent playlist exhaustion - Reduce crossfade to 3 seconds - Add audio buffering settings for stability - Add auto-reconnect logic for both front page and pop-out players
This commit is contained in:
parent
9721fbbc8a
commit
74cd3625f3
|
|
@ -277,6 +277,19 @@
|
||||||
("message" . ,(format nil "Error reordering queue: ~a" e)))
|
("message" . ,(format nil "Error reordering queue: ~a" e)))
|
||||||
:status 500))))
|
:status 500))))
|
||||||
|
|
||||||
|
(define-api asteroid/stream/queue/load-m3u () ()
|
||||||
|
"Load stream queue from stream-queue.m3u file"
|
||||||
|
(require-role :admin)
|
||||||
|
(handler-case
|
||||||
|
(let ((count (load-queue-from-m3u-file)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . "Queue loaded from M3U file")
|
||||||
|
("count" . ,count))))
|
||||||
|
(error (e)
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . ,(format nil "Error loading from M3U: ~a" e)))
|
||||||
|
:status 500))))
|
||||||
|
|
||||||
(defun get-track-by-id (track-id)
|
(defun get-track-by-id (track-id)
|
||||||
"Get a track by its ID - handles type mismatches"
|
"Get a track by its ID - handles type mismatches"
|
||||||
;; Try direct query first
|
;; Try direct query first
|
||||||
|
|
@ -824,6 +837,16 @@
|
||||||
:now-playing-album "Startup Sounds"
|
:now-playing-album "Startup Sounds"
|
||||||
:player-status "Stopped")))
|
:player-status "Stopped")))
|
||||||
|
|
||||||
|
(define-page popout-player #@"/popout-player" ()
|
||||||
|
"Pop-out player window"
|
||||||
|
(let ((template-path (merge-pathnames "template/popout-player.chtml"
|
||||||
|
(asdf:system-source-directory :asteroid))))
|
||||||
|
(clip:process-to-string
|
||||||
|
(plump:parse (alexandria:read-file-into-string template-path))
|
||||||
|
:stream-base-url *stream-base-url*
|
||||||
|
:default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
|
||||||
|
:default-stream-encoding "audio/aac")))
|
||||||
|
|
||||||
(define-api asteroid/status () ()
|
(define-api asteroid/status () ()
|
||||||
"Get server status"
|
"Get server status"
|
||||||
(api-output `(("status" . "running")
|
(api-output `(("status" . "running")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ set("init.allow_root", true)
|
||||||
# Set log level for debugging
|
# Set log level for debugging
|
||||||
log.level.set(4)
|
log.level.set(4)
|
||||||
|
|
||||||
|
# Audio buffering settings to prevent choppiness
|
||||||
|
settings.frame.audio.samplerate.set(44100)
|
||||||
|
settings.frame.audio.channels.set(2)
|
||||||
|
settings.audio.converter.samplerate.libsamplerate.quality.set("best")
|
||||||
|
|
||||||
# Enable telnet server for remote control
|
# Enable telnet server for remote control
|
||||||
settings.server.telnet.set(true)
|
settings.server.telnet.set(true)
|
||||||
settings.server.telnet.port.set(1234)
|
settings.server.telnet.port.set(1234)
|
||||||
|
|
@ -19,8 +24,8 @@ settings.server.telnet.bind_addr.set("0.0.0.0")
|
||||||
# Falls back to directory scan if playlist file doesn't exist
|
# Falls back to directory scan if playlist file doesn't exist
|
||||||
radio = playlist(
|
radio = playlist(
|
||||||
mode="normal", # Play in order (not randomized)
|
mode="normal", # Play in order (not randomized)
|
||||||
reload=5, # Check for playlist updates every 5 seconds
|
reload=30, # Check for playlist updates every 30 seconds
|
||||||
reload_mode="watch", # Watch file for changes
|
reload_mode="seconds", # Reload every N seconds (prevents running out of tracks)
|
||||||
"/app/stream-queue.m3u"
|
"/app/stream-queue.m3u"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,24 +39,11 @@ radio_fallback = playlist.safe(
|
||||||
# Use main playlist, fall back to directory scan
|
# Use main playlist, fall back to directory scan
|
||||||
radio = fallback(track_sensitive=false, [radio, radio_fallback])
|
radio = fallback(track_sensitive=false, [radio, radio_fallback])
|
||||||
|
|
||||||
# Add some audio processing
|
# Simple crossfade for smooth transitions
|
||||||
# Use ReplayGain for consistent volume without pumping
|
|
||||||
radio = amplify(1.0, override="replaygain", radio)
|
|
||||||
|
|
||||||
# Add smooth crossfade between tracks (5 seconds)
|
|
||||||
radio = crossfade(
|
radio = crossfade(
|
||||||
duration=5.0, # 5 second crossfade
|
duration=3.0, # 3 second crossfade
|
||||||
fade_in=3.0, # 3 second fade in
|
fade_in=2.0, # 2 second fade in
|
||||||
fade_out=3.0, # 3 second fade out
|
fade_out=2.0, # 2 second fade out
|
||||||
radio
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add a compressor to prevent clipping
|
|
||||||
radio = compress(
|
|
||||||
ratio=3.0, # Compression ratio
|
|
||||||
threshold=-15.0, # Threshold in dB
|
|
||||||
attack=50.0, # Attack time in ms
|
|
||||||
release=400.0, # Release time in ms
|
|
||||||
radio
|
radio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
// Queue controls
|
// Queue controls
|
||||||
const refreshQueueBtn = document.getElementById('refresh-queue');
|
const refreshQueueBtn = document.getElementById('refresh-queue');
|
||||||
|
const loadFromM3uBtn = document.getElementById('load-from-m3u');
|
||||||
const clearQueueBtn = document.getElementById('clear-queue-btn');
|
const clearQueueBtn = document.getElementById('clear-queue-btn');
|
||||||
const addRandomBtn = document.getElementById('add-random-tracks');
|
const addRandomBtn = document.getElementById('add-random-tracks');
|
||||||
const queueSearchInput = document.getElementById('queue-track-search');
|
const queueSearchInput = document.getElementById('queue-track-search');
|
||||||
|
|
||||||
if (refreshQueueBtn) refreshQueueBtn.addEventListener('click', loadStreamQueue);
|
if (refreshQueueBtn) refreshQueueBtn.addEventListener('click', loadStreamQueue);
|
||||||
|
if (loadFromM3uBtn) loadFromM3uBtn.addEventListener('click', loadQueueFromM3U);
|
||||||
if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue);
|
if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue);
|
||||||
if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks);
|
if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks);
|
||||||
if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue);
|
if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue);
|
||||||
|
|
@ -103,8 +105,6 @@ function renderPage() {
|
||||||
<div class="track-album">${track.album || 'Unknown Album'}</div>
|
<div class="track-album">${track.album || 'Unknown Album'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-actions">
|
<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="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>
|
<button onclick="deleteTrack(${track.id})" class="btn btn-sm btn-danger">🗑️ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -368,14 +368,20 @@ function displayStreamQueue() {
|
||||||
let html = '<div class="queue-items">';
|
let html = '<div class="queue-items">';
|
||||||
streamQueue.forEach((item, index) => {
|
streamQueue.forEach((item, index) => {
|
||||||
if (item) {
|
if (item) {
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === streamQueue.length - 1;
|
||||||
html += `
|
html += `
|
||||||
<div class="queue-item" data-track-id="${item.id}">
|
<div class="queue-item" data-track-id="${item.id}" data-index="${index}">
|
||||||
<span class="queue-position">${index + 1}</span>
|
<span class="queue-position">${index + 1}</span>
|
||||||
<div class="queue-track-info">
|
<div class="queue-track-info">
|
||||||
<div class="track-title">${item.title || 'Unknown'}</div>
|
<div class="track-title">${item.title || 'Unknown'}</div>
|
||||||
<div class="track-artist">${item.artist || 'Unknown Artist'}</div>
|
<div class="track-artist">${item.artist || 'Unknown Artist'}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">Remove</button>
|
<div class="queue-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="moveTrackUp(${index})" ${isFirst ? 'disabled' : ''}>⬆️</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="moveTrackDown(${index})" ${isLast ? 'disabled' : ''}>⬇️</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">Remove</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -410,6 +416,74 @@ async function clearStreamQueue() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load queue from M3U file
|
||||||
|
async function loadQueueFromM3U() {
|
||||||
|
if (!confirm('Load queue from stream-queue.m3u file? This will replace the current queue.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/asteroid/stream/queue/load-m3u', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alert(`Successfully loaded ${data.count} tracks from M3U file!`);
|
||||||
|
loadStreamQueue();
|
||||||
|
} else {
|
||||||
|
alert('Error loading from M3U: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading from M3U:', error);
|
||||||
|
alert('Error loading from M3U: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move track up in queue
|
||||||
|
async function moveTrackUp(index) {
|
||||||
|
if (index === 0) return;
|
||||||
|
|
||||||
|
// Swap with previous track
|
||||||
|
const newQueue = [...streamQueue];
|
||||||
|
[newQueue[index - 1], newQueue[index]] = [newQueue[index], newQueue[index - 1]];
|
||||||
|
|
||||||
|
await reorderQueue(newQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move track down in queue
|
||||||
|
async function moveTrackDown(index) {
|
||||||
|
if (index === streamQueue.length - 1) return;
|
||||||
|
|
||||||
|
// Swap with next track
|
||||||
|
const newQueue = [...streamQueue];
|
||||||
|
[newQueue[index], newQueue[index + 1]] = [newQueue[index + 1], newQueue[index]];
|
||||||
|
|
||||||
|
await reorderQueue(newQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder the queue
|
||||||
|
async function reorderQueue(newQueue) {
|
||||||
|
try {
|
||||||
|
const trackIds = newQueue.map(track => track.id).join(',');
|
||||||
|
const response = await fetch('/api/asteroid/stream/queue/reorder?track-ids=' + trackIds, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
loadStreamQueue();
|
||||||
|
} else {
|
||||||
|
alert('Error reordering queue: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reordering queue:', error);
|
||||||
|
alert('Error reordering queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove track from queue
|
// Remove track from queue
|
||||||
async function removeFromQueue(trackId) {
|
async function removeFromQueue(trackId) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,83 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
// Update playing information right after load
|
// Update playing information right after load
|
||||||
updateNowPlaying();
|
updateNowPlaying();
|
||||||
|
|
||||||
|
// Auto-reconnect on stream errors
|
||||||
|
const audioElement = document.getElementById('live-audio');
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.addEventListener('error', function(e) {
|
||||||
|
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||||
|
setTimeout(function() {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioElement.addEventListener('stalled', function() {
|
||||||
|
console.log('Stream stalled, reloading...');
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reload failed:', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update every 10 seconds
|
// Update every 10 seconds
|
||||||
setInterval(updateNowPlaying, 10000);
|
setInterval(updateNowPlaying, 10000);
|
||||||
|
|
||||||
|
// Pop-out player functionality
|
||||||
|
let popoutWindow = null;
|
||||||
|
|
||||||
|
function openPopoutPlayer() {
|
||||||
|
// Check if popout is already open
|
||||||
|
if (popoutWindow && !popoutWindow.closed) {
|
||||||
|
popoutWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate centered position
|
||||||
|
const width = 420;
|
||||||
|
const height = 300;
|
||||||
|
const left = (screen.width - width) / 2;
|
||||||
|
const top = (screen.height - height) / 2;
|
||||||
|
|
||||||
|
// Open popout window
|
||||||
|
const features = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no,status=no,menubar=no,toolbar=no,location=no`;
|
||||||
|
|
||||||
|
popoutWindow = window.open('/asteroid/popout-player', 'AsteroidPlayer', features);
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
updatePopoutButton(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePopoutButton(isOpen) {
|
||||||
|
const btn = document.getElementById('popout-btn');
|
||||||
|
if (btn) {
|
||||||
|
if (isOpen) {
|
||||||
|
btn.textContent = '✓ Player Open';
|
||||||
|
btn.classList.remove('btn-info');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
} else {
|
||||||
|
btn.textContent = '🗗 Pop Out Player';
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from popout window
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.data.type === 'popout-opened') {
|
||||||
|
updatePopoutButton(true);
|
||||||
|
} else if (event.data.type === 'popout-closed') {
|
||||||
|
updatePopoutButton(false);
|
||||||
|
popoutWindow = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if popout is still open periodically
|
||||||
|
setInterval(function() {
|
||||||
|
if (popoutWindow && popoutWindow.closed) {
|
||||||
|
updatePopoutButton(false);
|
||||||
|
popoutWindow = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
|
||||||
|
|
@ -177,3 +177,46 @@
|
||||||
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
|
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
|
||||||
(regenerate-stream-playlist)
|
(regenerate-stream-playlist)
|
||||||
*stream-queue*))))
|
*stream-queue*))))
|
||||||
|
|
||||||
|
(defun convert-from-docker-path (docker-path)
|
||||||
|
"Convert Docker container path back to host file path"
|
||||||
|
(if (and (stringp docker-path)
|
||||||
|
(>= (length docker-path) 11)
|
||||||
|
(string= docker-path "/app/music/" :end1 11))
|
||||||
|
(concatenate 'string
|
||||||
|
(namestring *music-library-path*)
|
||||||
|
(subseq docker-path 11))
|
||||||
|
docker-path))
|
||||||
|
|
||||||
|
(defun load-queue-from-m3u-file ()
|
||||||
|
"Load the stream queue from the stream-queue.m3u file"
|
||||||
|
(let* ((m3u-path (merge-pathnames "stream-queue.m3u"
|
||||||
|
(asdf:system-source-directory :asteroid)))
|
||||||
|
(track-ids '())
|
||||||
|
(all-tracks (db:select "tracks" (db:query :all))))
|
||||||
|
|
||||||
|
(when (probe-file m3u-path)
|
||||||
|
(with-open-file (stream m3u-path :direction :input)
|
||||||
|
(loop for line = (read-line stream nil)
|
||||||
|
while line
|
||||||
|
do (unless (or (string= line "")
|
||||||
|
(char= (char line 0) #\#))
|
||||||
|
;; This is a file path line
|
||||||
|
(let* ((docker-path (string-trim '(#\Space #\Tab #\Return #\Newline) line))
|
||||||
|
(host-path (convert-from-docker-path docker-path)))
|
||||||
|
;; Find track by file path
|
||||||
|
(let ((track (find-if
|
||||||
|
(lambda (trk)
|
||||||
|
(let ((fp (gethash "file-path" trk)))
|
||||||
|
(let ((file-path (if (listp fp) (first fp) fp)))
|
||||||
|
(string= file-path host-path))))
|
||||||
|
all-tracks)))
|
||||||
|
(when track
|
||||||
|
(let ((id (gethash "_id" track)))
|
||||||
|
(push (if (listp id) (first id) id) track-ids)))))))))
|
||||||
|
|
||||||
|
;; Reverse to maintain order from file
|
||||||
|
(setf track-ids (nreverse track-ids))
|
||||||
|
(setf *stream-queue* track-ids)
|
||||||
|
(regenerate-stream-playlist)
|
||||||
|
(length track-ids)))
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1 @@
|
||||||
#EXTM3U
|
#EXTM3U
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Kraftwerk/1978 - The Man-Machine \[2009 Digital Remaster]/02 - Spacelab.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Kraftwerk/1981 - Computer World \[2009 Digital Remaster]/03 - Numbers.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Model500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/This Mortal Coil/1984 - It'll End In Tears/09 - Barramundi.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Underworld/1996 - Second Toughest In The Infants/04. Underworld - Rowla.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/This Mortal Coil/1984 - It'll End In Tears/10 - Dreams Made Flesh.flac
|
|
||||||
#EXTINF:0,
|
|
||||||
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@
|
||||||
|
|
||||||
<div class="queue-controls">
|
<div class="queue-controls">
|
||||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||||||
|
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</button>
|
||||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear 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>
|
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="live-stream">
|
<div class="live-stream">
|
||||||
<h2 style="color: #00ff00;">🟢 LIVE STREAM</h2>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<h2 style="color: #00ff00; margin: 0;">🟢 LIVE STREAM</h2>
|
||||||
|
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
|
||||||
|
🗗 Pop Out Player
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stream Quality Selector -->
|
<!-- Stream Quality Selector -->
|
||||||
<div class="live-stream-quality">
|
<div class="live-stream-quality">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>🎵 Asteroid Radio - Player</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.popout-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.popout-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
.popout-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #ff6666;
|
||||||
|
}
|
||||||
|
.now-playing-mini {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
.track-info-mini {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.track-title-mini {
|
||||||
|
color: #00ff00;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.track-artist-mini {
|
||||||
|
color: #4488ff;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.quality-selector {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
.quality-selector label {
|
||||||
|
color: #00ff00;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.quality-selector select {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #00ff00;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
audio {
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.status-mini {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="/asteroid/static/js/front-page.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="popout-container">
|
||||||
|
<div class="popout-header">
|
||||||
|
<div class="popout-title">🎵 Asteroid Radio</div>
|
||||||
|
<button class="close-btn" onclick="window.close()">✖ Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="now-playing-mini">
|
||||||
|
<div class="track-info-mini">
|
||||||
|
<div class="track-title-mini" id="popout-track-title">Loading...</div>
|
||||||
|
<div class="track-artist-mini" id="popout-track-artist">Please wait</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quality-selector">
|
||||||
|
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||||
|
<label for="popout-stream-quality"><strong>Quality:</strong></label>
|
||||||
|
<select id="popout-stream-quality" onchange="changeStreamQuality()">
|
||||||
|
<option value="aac">AAC 96kbps</option>
|
||||||
|
<option value="mp3">MP3 128kbps</option>
|
||||||
|
<option value="low">MP3 64kbps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="live-audio" controls autoplay style="width: 100%;">
|
||||||
|
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<div class="status-mini">
|
||||||
|
<span style="color: #00ff00;">● LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Stream quality configuration for popout
|
||||||
|
function getStreamConfig(streamBaseUrl, encoding) {
|
||||||
|
const config = {
|
||||||
|
aac: {
|
||||||
|
url: `${streamBaseUrl}/asteroid.aac`,
|
||||||
|
format: 'AAC 96kbps Stereo',
|
||||||
|
type: 'audio/aac',
|
||||||
|
mount: 'asteroid.aac'
|
||||||
|
},
|
||||||
|
mp3: {
|
||||||
|
url: `${streamBaseUrl}/asteroid.mp3`,
|
||||||
|
format: 'MP3 128kbps Stereo',
|
||||||
|
type: 'audio/mpeg',
|
||||||
|
mount: 'asteroid.mp3'
|
||||||
|
},
|
||||||
|
low: {
|
||||||
|
url: `${streamBaseUrl}/asteroid-low.mp3`,
|
||||||
|
format: 'MP3 64kbps Stereo',
|
||||||
|
type: 'audio/mpeg',
|
||||||
|
mount: 'asteroid-low.mp3'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return config[encoding];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change stream quality in popout
|
||||||
|
function changeStreamQuality() {
|
||||||
|
const selector = document.getElementById('popout-stream-quality');
|
||||||
|
const streamBaseUrl = document.getElementById('stream-base-url');
|
||||||
|
const config = getStreamConfig(streamBaseUrl.value, selector.value);
|
||||||
|
|
||||||
|
// Update audio player
|
||||||
|
const audioElement = document.getElementById('live-audio');
|
||||||
|
const sourceElement = document.getElementById('audio-source');
|
||||||
|
|
||||||
|
const wasPlaying = !audioElement.paused;
|
||||||
|
|
||||||
|
sourceElement.src = config.url;
|
||||||
|
sourceElement.type = config.type;
|
||||||
|
audioElement.load();
|
||||||
|
|
||||||
|
// Resume playback if it was playing
|
||||||
|
if (wasPlaying) {
|
||||||
|
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update now playing info for popout
|
||||||
|
async function updatePopoutNowPlaying() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// Parse the HTML to extract track info
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
const trackText = doc.body.textContent || doc.body.innerText || '';
|
||||||
|
|
||||||
|
// Try to split artist - title format
|
||||||
|
const parts = trackText.split(' - ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
document.getElementById('popout-track-artist').textContent = parts[0].trim();
|
||||||
|
document.getElementById('popout-track-title').textContent = parts.slice(1).join(' - ').trim();
|
||||||
|
} else {
|
||||||
|
document.getElementById('popout-track-title').textContent = trackText.trim();
|
||||||
|
document.getElementById('popout-track-artist').textContent = 'Asteroid Radio';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating now playing:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update every 10 seconds
|
||||||
|
setInterval(updatePopoutNowPlaying, 10000);
|
||||||
|
// Initial update
|
||||||
|
updatePopoutNowPlaying();
|
||||||
|
|
||||||
|
// Auto-reconnect on stream errors
|
||||||
|
const audioElement = document.getElementById('live-audio');
|
||||||
|
audioElement.addEventListener('error', function(e) {
|
||||||
|
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||||
|
setTimeout(function() {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioElement.addEventListener('stalled', function() {
|
||||||
|
console.log('Stream stalled, reloading...');
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reload failed:', err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent window that popout is open
|
||||||
|
if (window.opener && !window.opener.closed) {
|
||||||
|
window.opener.postMessage({ type: 'popout-opened' }, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent when closing
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (window.opener && !window.opener.closed) {
|
||||||
|
window.opener.postMessage({ type: 'popout-closed' }, '*');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue