From 74cd3625f331065207e9b14946d1d733bd08deb8 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 19 Oct 2025 13:52:59 +0300 Subject: [PATCH 1/3] 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 +
+
+ + + + From 01f5806959a224e480dcbda816eceb5703431eb7 Mon Sep 17 00:00:00 2001 From: glenneth Date: Tue, 21 Oct 2025 21:50:39 +0300 Subject: [PATCH 2/3] feat: Add hybrid player with frameset and pop-out options - Add frameset mode with persistent audio player in bottom frame - Add localStorage preference system for user choice - Update all page navigation to work in both regular and frameset modes - Add enable/disable buttons for frameset mode - Fix redirect loops and template parameter issues --- asteroid.lisp | 52 ++++++++- static/js/front-page.js | 27 +++++ template/admin.chtml | 6 +- template/audio-player-frame.chtml | 174 ++++++++++++++++++++++++++++++ template/frameset-wrapper.chtml | 23 ++++ template/front-page-content.chtml | 47 ++++++++ template/front-page.chtml | 11 +- template/login.chtml | 6 +- template/player-content.chtml | 120 +++++++++++++++++++++ template/player.chtml | 6 +- template/profile.chtml | 6 +- template/register.chtml | 6 +- template/users.chtml | 4 +- 13 files changed, 467 insertions(+), 21 deletions(-) create mode 100644 template/audio-player-frame.chtml create mode 100644 template/frameset-wrapper.chtml create mode 100644 template/front-page-content.chtml create mode 100644 template/player-content.chtml diff --git a/asteroid.lisp b/asteroid.lisp index eb07dcf..7f61005 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -516,7 +516,7 @@ ("message" . "Listening history cleared successfully")))) |# -;; Front page +;; Front page - regular view by default (define-page front-page #@"/" () "Main front page" (let ((template-path (merge-pathnames "template/front-page.chtml" @@ -537,6 +537,44 @@ :now-playing-album "Startup Sounds" :now-playing-duration "โˆž"))) +;; Frameset wrapper for persistent player mode +(define-page frameset-wrapper #@"/frameset" () + "Frameset wrapper with persistent audio player" + (let ((template-path (merge-pathnames "template/frameset-wrapper.chtml" + (asdf:system-source-directory :asteroid)))) + (clip:process-to-string + (plump:parse (alexandria:read-file-into-string template-path)) + :title "๐ŸŽต ASTEROID RADIO ๐ŸŽต"))) + +;; Content frame - front page content without player +(define-page front-page-content #@"/content" () + "Front page content (displayed in content frame)" + (let ((template-path (merge-pathnames "template/front-page-content.chtml" + (asdf:system-source-directory :asteroid)))) + (clip:process-to-string + (plump:parse (alexandria:read-file-into-string template-path)) + :title "๐ŸŽต ASTEROID RADIO ๐ŸŽต" + :station-name "๐ŸŽต ASTEROID RADIO ๐ŸŽต" + :status-message "๐ŸŸข LIVE - Broadcasting asteroid music for hackers" + :listeners "0" + :stream-quality "128kbps MP3" + :stream-base-url *stream-base-url* + :now-playing-artist "The Void" + :now-playing-track "Silence" + :now-playing-album "Startup Sounds" + :now-playing-duration "โˆž"))) + +;; Persistent audio player frame (bottom frame) +(define-page audio-player-frame #@"/audio-player-frame" () + "Persistent audio player frame (bottom of page)" + (let ((template-path (merge-pathnames "template/audio-player-frame.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"))) + ;; Configure static file serving for other files (define-page static #@"/static/(.*)" (:uri-groups (path)) (serve-file (merge-pathnames (concatenate 'string "static/" path) @@ -837,6 +875,18 @@ :now-playing-album "Startup Sounds" :player-status "Stopped"))) +;; Player content frame (for frameset mode) +(define-page player-content #@"/player-content" () + "Player page content (displayed in content frame)" + (let ((template-path (merge-pathnames "template/player-content.chtml" + (asdf:system-source-directory :asteroid)))) + (clip:process-to-string + (plump:parse (alexandria:read-file-into-string template-path)) + :title "Asteroid Radio - Web Player" + :stream-base-url *stream-base-url* + :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac") + :default-stream-encoding "audio/aac"))) + (define-page popout-player #@"/popout-player" () "Pop-out player window" (let ((template-path (merge-pathnames "template/popout-player.chtml" diff --git a/static/js/front-page.js b/static/js/front-page.js index c841b5a..f21fb68 100644 --- a/static/js/front-page.js +++ b/static/js/front-page.js @@ -169,3 +169,30 @@ setInterval(function() { popoutWindow = null; } }, 1000); + +// Frameset mode functionality +function enableFramesetMode() { + // Save preference + localStorage.setItem('useFrameset', 'true'); + // Redirect to frameset wrapper + window.location.href = '/asteroid/frameset'; +} + +function disableFramesetMode() { + // Clear preference + localStorage.removeItem('useFrameset'); + // Redirect to regular view + window.location.href = '/asteroid/'; +} + +// Check if user prefers frameset mode on page load +window.addEventListener('DOMContentLoaded', function() { + const path = window.location.pathname; + const isFramesetPage = path.includes('/frameset') || path.includes('/content') || + path.includes('/audio-player-frame') || path.includes('/player-content'); + + if (localStorage.getItem('useFrameset') === 'true' && !isFramesetPage && path === '/asteroid/') { + // User wants frameset but is on regular front page, redirect + window.location.href = '/asteroid/frameset'; + } +}); diff --git a/template/admin.chtml b/template/admin.chtml index ecfa9a5..5310f18 100644 --- a/template/admin.chtml +++ b/template/admin.chtml @@ -12,9 +12,9 @@

๐ŸŽ›๏ธ ADMIN DASHBOARD

diff --git a/template/audio-player-frame.chtml b/template/audio-player-frame.chtml new file mode 100644 index 0000000..a7d8669 --- /dev/null +++ b/template/audio-player-frame.chtml @@ -0,0 +1,174 @@ + + + + + + + + + +
+ ๐ŸŸข LIVE: + +
+ + + +
+ + + + Loading... + + +
+ + + + diff --git a/template/frameset-wrapper.chtml b/template/frameset-wrapper.chtml new file mode 100644 index 0000000..0cf1f2e --- /dev/null +++ b/template/frameset-wrapper.chtml @@ -0,0 +1,23 @@ + + + + ๐ŸŽต ASTEROID RADIO ๐ŸŽต + + + + + + + + + <body> + <p>Your browser does not support frames. Please use a modern browser or visit <a href="/asteroid/content">the main site</a>.</p> + </body> + + + diff --git a/template/front-page-content.chtml b/template/front-page-content.chtml new file mode 100644 index 0000000..f4e8c97 --- /dev/null +++ b/template/front-page-content.chtml @@ -0,0 +1,47 @@ + + + + ๐ŸŽต ASTEROID RADIO ๐ŸŽต + + + + + + + +
+
+

๐ŸŽต ASTEROID RADIO ๐ŸŽต

+ +
+ +
+
+

Station Status

+

๐ŸŸข LIVE - Broadcasting asteroid music for hackers

+

Current listeners: 0

+

Stream quality: AAC 96kbps Stereo

+
+ +
+

๐ŸŸข LIVE STREAM

+

The live stream player is now in the persistent bar at the bottom of the page.

+

Stream URL:

+

Format:

+

Status: โ— BROADCASTING

+
+ +
+
+
+ + diff --git a/template/front-page.chtml b/template/front-page.chtml index cbf1ee5..dff4977 100644 --- a/template/front-page.chtml +++ b/template/front-page.chtml @@ -35,9 +35,14 @@

๐ŸŸข LIVE STREAM

- +
+ + +
diff --git a/template/login.chtml b/template/login.chtml index 7c315c2..584ab07 100644 --- a/template/login.chtml +++ b/template/login.chtml @@ -11,9 +11,9 @@

๐ŸŽต ASTEROID RADIO - LOGIN

diff --git a/template/player-content.chtml b/template/player-content.chtml new file mode 100644 index 0000000..c347c46 --- /dev/null +++ b/template/player-content.chtml @@ -0,0 +1,120 @@ + + + + Asteroid Radio - Web Player + + + + + + + +
+

๐ŸŽต WEB PLAYER

+ + + +
+

๐ŸŸข Live Radio Stream

+

The live stream player is now in the persistent bar at the bottom of the page. It will continue playing as you navigate between pages!

+
+ +
+ + +
+

Personal Track Library

+
+ + +
+
Loading tracks...
+
+ + +
+
+ + +
+

Audio Player

+
+
+
๐ŸŽต
+
+
No track selected
+
Unknown Artist
+
Unknown Album
+
+
+ + + +
+ + + + + +
+ +
+
+ 0:00 / 0:00 +
+
+ + +
+
+
+
+ + +
+

Playlists

+
+ + +
+ +
+
+
No playlists created yet.
+
+
+
+ + +
+

Play Queue

+
+ + +
+
+
Queue is empty
+
+
+
+ + diff --git a/template/player.chtml b/template/player.chtml index 5911737..4ddcb91 100644 --- a/template/player.chtml +++ b/template/player.chtml @@ -12,9 +12,9 @@

๐ŸŽต WEB PLAYER