From 74cd3625f331065207e9b14946d1d733bd08deb8 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 19 Oct 2025 13:52:59 +0300 Subject: [PATCH] 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 --- asteroid.lisp | 23 +++ docker/asteroid-radio-docker.liq | 30 ++-- static/js/admin.js | 82 ++++++++++- static/js/front-page.js | 76 ++++++++++ stream-control.lisp | 43 ++++++ stream-queue.m3u | 18 --- template/admin.chtml | 1 + template/front-page.chtml | 7 +- template/popout-player.chtml | 234 +++++++++++++++++++++++++++++++ 9 files changed, 472 insertions(+), 42 deletions(-) create mode 100644 template/popout-player.chtml diff --git a/asteroid.lisp b/asteroid.lisp index 1f45c77..eb07dcf 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -277,6 +277,19 @@ ("message" . ,(format nil "Error reordering queue: ~a" e))) :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) "Get a track by its ID - handles type mismatches" ;; Try direct query first @@ -824,6 +837,16 @@ :now-playing-album "Startup Sounds" :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 () () "Get server status" (api-output `(("status" . "running") diff --git a/docker/asteroid-radio-docker.liq b/docker/asteroid-radio-docker.liq index 6831702..953b67e 100644 --- a/docker/asteroid-radio-docker.liq +++ b/docker/asteroid-radio-docker.liq @@ -9,6 +9,11 @@ set("init.allow_root", true) # Set log level for debugging 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 settings.server.telnet.set(true) 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 radio = playlist( mode="normal", # Play in order (not randomized) - reload=5, # Check for playlist updates every 5 seconds - reload_mode="watch", # Watch file for changes + reload=30, # Check for playlist updates every 30 seconds + reload_mode="seconds", # Reload every N seconds (prevents running out of tracks) "/app/stream-queue.m3u" ) @@ -34,24 +39,11 @@ radio_fallback = playlist.safe( # Use main playlist, fall back to directory scan radio = fallback(track_sensitive=false, [radio, radio_fallback]) -# Add some audio processing -# Use ReplayGain for consistent volume without pumping -radio = amplify(1.0, override="replaygain", radio) - -# Add smooth crossfade between tracks (5 seconds) +# Simple crossfade for smooth transitions radio = crossfade( - duration=5.0, # 5 second crossfade - fade_in=3.0, # 3 second fade in - fade_out=3.0, # 3 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 + duration=3.0, # 3 second crossfade + fade_in=2.0, # 2 second fade in + fade_out=2.0, # 2 second fade out radio ) diff --git a/static/js/admin.js b/static/js/admin.js index 0626b27..a69f9ca 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -28,11 +28,13 @@ document.addEventListener('DOMContentLoaded', function() { // Queue controls const refreshQueueBtn = document.getElementById('refresh-queue'); + const loadFromM3uBtn = document.getElementById('load-from-m3u'); 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 (loadFromM3uBtn) loadFromM3uBtn.addEventListener('click', loadQueueFromM3U); if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue); if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks); if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue); @@ -103,8 +105,6 @@ function renderPage() {
${track.album || 'Unknown Album'}
- -
@@ -368,14 +368,20 @@ function displayStreamQueue() { let html = '
'; streamQueue.forEach((item, index) => { if (item) { + const isFirst = index === 0; + const isLast = index === streamQueue.length - 1; html += ` -
+
${index + 1}
${item.title || 'Unknown'}
${item.artist || 'Unknown Artist'}
- +
+ + + +
`; } @@ -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 async function removeFromQueue(trackId) { try { diff --git a/static/js/front-page.js b/static/js/front-page.js index 2ef1ea8..c841b5a 100644 --- a/static/js/front-page.js +++ b/static/js/front-page.js @@ -89,7 +89,83 @@ window.addEventListener('DOMContentLoaded', function() { } // Update playing information right after load 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 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); diff --git a/stream-control.lisp b/stream-control.lisp index 3bad8ee..2eef56d 100644 --- a/stream-control.lisp +++ b/stream-control.lisp @@ -177,3 +177,46 @@ (setf *stream-queue* (subseq track-ids 0 (min count (length track-ids)))) (regenerate-stream-playlist) *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))) diff --git a/stream-queue.m3u b/stream-queue.m3u index 4288342..fcd7187 100644 --- a/stream-queue.m3u +++ b/stream-queue.m3u @@ -1,19 +1 @@ #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 diff --git a/template/admin.chtml b/template/admin.chtml index 0397ba9..ecfa9a5 100644 --- a/template/admin.chtml +++ b/template/admin.chtml @@ -127,6 +127,7 @@
+
diff --git a/template/front-page.chtml b/template/front-page.chtml index 43d2694..cbf1ee5 100644 --- a/template/front-page.chtml +++ b/template/front-page.chtml @@ -33,7 +33,12 @@
-

🟢 LIVE STREAM

+
+

🟢 LIVE STREAM

+ +
diff --git a/template/popout-player.chtml b/template/popout-player.chtml new file mode 100644 index 0000000..d34b0cb --- /dev/null +++ b/template/popout-player.chtml @@ -0,0 +1,234 @@ + + + + 🎵 Asteroid Radio - Player + + + + + + + +
+
+
🎵 Asteroid Radio
+ +
+ +
+
+
Loading...
+
Please wait
+
+
+ +
+ + + +
+ + + +
+ ● LIVE +
+
+ + + +