feat: Implement frameset-based persistent audio player

- Add frameset architecture for persistent audio playback across navigation
- Create frameset-wrapper.chtml as main container with two frames
- Create audio-player-frame.chtml for persistent 80px bottom player frame
- Create front-page-content.chtml and player-content.chtml for frame content
- Add routes for frameset wrapper, content frames, and player frame
- Audio continues playing when navigating between pages
- Pure Lisp/HTML solution with no additional JavaScript dependencies
- Player frame includes quality selector and now-playing display
- Updates every 10 seconds via /api/asteroid/partial/now-playing-inline
This commit is contained in:
glenneth 2025-10-19 15:09:24 +03:00
parent 9721fbbc8a
commit 4288b4a1ea
13 changed files with 591 additions and 41 deletions

View File

@ -250,6 +250,23 @@
("message" . ,(format nil "Error clearing queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/load-m3u () ()
"Load queue from stream-queue.m3u file"
(require-role :admin)
(handler-case
(let ((count (or (load-queue-from-m3u) 0)))
(if (numberp count)
(api-output `(("status" . "success")
("message" . "Queue loaded from M3U file")
("count" . ,count)))
(api-output `(("status" . "error")
("message" . "Failed to load queue from M3U file"))
:status 500)))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error loading from M3U: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/add-playlist (playlist-id) ()
"Add all tracks from a playlist to the stream queue"
(require-role :admin)
@ -503,10 +520,19 @@
("message" . "Listening history cleared successfully"))))
|#
;; Front page
;; Front page - now serves frameset wrapper
(define-page front-page #@"/" ()
"Main front page"
(let ((template-path (merge-pathnames "template/front-page.chtml"
"Main front page with persistent audio player frame"
(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 - the actual front page content
(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))
@ -524,6 +550,17 @@
:now-playing-album "Startup Sounds"
:now-playing-duration "∞")))
;; Persistent audio player 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)
@ -810,8 +847,15 @@
:error-message ""
:success-message ""))))
;; Player page - redirects to content frame version
(define-page player #@"/player" ()
(let ((template-path (merge-pathnames "template/player.chtml"
"Redirect to player content in frameset"
(radiance:redirect "/asteroid/"))
;; Player content frame
(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))
@ -890,6 +934,24 @@
(setf (radiance:environment) "default"))
(radiance:startup)
;; Load the stream queue from M3U file after database is ready
(bt:make-thread
(lambda ()
(format t "Queue loader thread started, waiting for database...~%")
(sleep 3) ; Wait for database to be ready
(handler-case
(progn
(format t "Attempting to load stream queue from M3U file...~%")
(if (db:connected-p)
(progn
(format t "Database is connected, loading queue...~%")
(load-queue-from-m3u))
(format t "Database not connected yet, skipping queue load~%")))
(error (e)
(format t "✗ Warning: Could not load stream queue: ~a~%" e))))
:name "queue-loader")
(format t "Server started! Visit http://localhost:~a/asteroid/~%" port))
(defun stop-server ()

View File

@ -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() {
<div class="track-album">${track.album || 'Unknown Album'}</div>
</div>
<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>
@ -368,14 +368,20 @@ function displayStreamQueue() {
let html = '<div class="queue-items">';
streamQueue.forEach((item, index) => {
if (item) {
const isFirst = index === 0;
const isLast = index === streamQueue.length - 1;
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>
<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 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>
`;
}
@ -584,3 +590,71 @@ async function updateLiveStreamInfo() {
}
}
}
// 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: ' + error.message);
}
}

View File

@ -96,6 +96,54 @@
(format stream "~a~%" docker-path))))))
t)
(defun load-queue-from-m3u ()
"Load the stream queue from the existing M3U file"
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
(asdf:system-source-directory :asteroid))))
(format t "Checking for M3U file at: ~a~%" playlist-path)
(if (probe-file playlist-path)
(handler-case
(progn
(format t "M3U file found, loading...~%")
(format t "Available collections: ~a~%" (db:collections))
(let ((all-tracks (db:select "tracks" (db:query :all))))
(format t "Found ~d tracks in database~%" (length all-tracks))
(when (> (length all-tracks) 0)
(format t "Sample track: ~a~%" (first all-tracks)))
(when (= (length all-tracks) 0)
(format t "⚠ Warning: No tracks in database. Please scan your music library first!~%")
(format t " Visit the Admin page and click 'Scan Library' to add tracks.~%")
(format t " After scanning, restart the server to load the queue.~%")
(return-from load-queue-from-m3u nil))
(with-open-file (stream playlist-path :direction :input)
(let ((track-ids '())
(line-count 0))
(loop for line = (read-line stream nil)
while line
do (progn
(incf line-count)
(when (and (> (length line) 0)
(not (char= (char line 0) #\#)))
;; This is a file path line, find the track ID
(format t "Processing line ~d: ~a~%" line-count line)
(dolist (track all-tracks)
(let* ((file-path (gethash "file-path" track))
(file-path-str (if (listp file-path) (first file-path) file-path))
(docker-path (convert-to-docker-path file-path-str)))
(when (string= docker-path line)
(let ((id (gethash "_id" track)))
(push (if (listp id) (first id) id) track-ids)
(format t " Matched track ID: ~a~%" id))))))))
(setf *stream-queue* (nreverse track-ids))
(format t "✓ Loaded ~d tracks from stream-queue.m3u~%" (length *stream-queue*))
(length *stream-queue*)))))
(error (e)
(format t "✗ Error loading queue from M3U: ~a~%" e)
nil))
(progn
(format t "✗ M3U file not found at: ~a~%" playlist-path)
nil))))
(defun regenerate-stream-playlist ()
"Regenerate the main stream playlist from the current queue"
(let ((playlist-path (merge-pathnames "stream-queue.m3u"

View File

@ -1,19 +1,37 @@
#EXTM3U
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
/app/music/Vector Lovers/2005 - Capsule For One/08 - Empty Buildings, Falling Rain.mp3
#EXTINF:0,
/app/music/Kraftwerk/1978 - The Man-Machine \[2009 Digital Remaster]/02 - Spacelab.flac
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/01 Little Fluffy Clouds.mp3
#EXTINF:0,
/app/music/Kraftwerk/1981 - Computer World \[2009 Digital Remaster]/03 - Numbers.flac
/app/music/Underworld/1996 - Second Toughest In The Infants/01. Underworld - Juanita, Kiteless, To Dream Of Love.flac
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
/app/music/Vector Lovers/2005 - Capsule For One/02 - Arrival, Metropolis.mp3
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
/app/music/Vector Lovers/2005 - Capsule For One/11 - Capsule For One.mp3
#EXTINF:0,
/app/music/Underworld/1996 - Second Toughest In The Infants/05. Underworld - Pearls Girl.flac
#EXTINF:0,
/app/music/Kraftwerk/1978 - The Man-Machine/04 - The Model.flac
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/01-model_500-hi_nrg.flac
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/07-model_500-station.flac
#EXTINF:0,
/app/music/Model500/\[1985] - Night Drive/01. Night Drive (Thru Babylon).mp3
#EXTINF:0,
/app/music/Model500/\[1988] - Interference/05. OK Corral.mp3
#EXTINF:0,
/app/music/This Mortal Coil/1984 - It'll End In Tears/02 - Song to the Siren.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
/app/music/Boards of Canada/1998 - Music Has the Right to Children/07 Turquoise Hexagon Sun.flac
#EXTINF:0,
/app/music/This Mortal Coil/1984 - It'll End In Tears/10 - Dreams Made Flesh.flac
/app/music/Aphex Twin/1992 - Selected Ambient Works 85-92/09 Schottkey 7Th Path.flac
#EXTINF:0,
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac
/app/music/Aphex Twin/1992 - Selected Ambient Works 85-92/08 We Are the Music Makers.flac
#EXTINF:0,
/app/music/LaBradford/1995 - A Stable Reference/2 El Lago.flac
#EXTINF:0,
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Planet Of The Shapes.mp3

View File

@ -12,11 +12,11 @@
<div class="container">
<h1>🎛️ ADMIN DASHBOARD</h1>
<div class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player/">Player</a>
<a href="/asteroid/profile">Profile</a>
<a href="/asteroid/admin/users">👥 Users</a>
<a href="/asteroid/logout" class="btn-logout">Logout</a>
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/profile" target="content-frame">Profile</a>
<a href="/asteroid/admin/user" target="content-frame">👥 Users</a>
<a href="/asteroid/logout" target="_top" class="btn-logout">Logout</a>
</div>
<!-- System Status -->
@ -127,6 +127,7 @@
<div class="queue-controls">
<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="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
</div>

View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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: #1a1a1a;
font-family: 'Courier New', monospace;
}
.persistent-player {
display: flex;
align-items: center;
gap: 15px;
max-width: 100%;
}
.player-label {
color: #00ff00;
font-weight: bold;
white-space: nowrap;
}
.quality-selector {
display: flex;
align-items: center;
gap: 5px;
}
.quality-selector label {
color: #00ff00;
font-size: 0.9em;
}
.quality-selector select {
background: #2a2a2a;
color: #00ff00;
border: 1px solid #00ff00;
padding: 3px 8px;
font-family: 'Courier New', monospace;
}
audio {
flex: 1;
min-width: 200px;
}
.now-playing-mini {
color: #00ff00;
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 300px;
}
</style>
</head>
<body>
<div class="persistent-player">
<span class="player-label">🟢 LIVE:</span>
<div class="quality-selector">
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<label for="stream-quality">Quality:</label>
<select id="stream-quality" onchange="changeStreamQuality()">
<option value="aac">AAC 96k</option>
<option value="mp3">MP3 128k</option>
<option value="low">MP3 64k</option>
</select>
</div>
<audio id="persistent-audio" controls preload="metadata">
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
</audio>
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
</div>
<script>
// Configure audio element for better streaming
document.addEventListener('DOMContentLoaded', function() {
const audioElement = document.getElementById('persistent-audio');
// Try to enable low-latency mode if supported
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Asteroid Radio Live Stream',
artist: 'Asteroid Radio',
album: 'Live Broadcast'
});
}
// Add event listeners for debugging
audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
});
});
// Stream quality configuration
function getStreamConfig(streamBaseUrl, encoding) {
const config = {
aac: {
url: streamBaseUrl + '/asteroid.aac',
type: 'audio/aac'
},
mp3: {
url: streamBaseUrl + '/asteroid.mp3',
type: 'audio/mpeg'
},
low: {
url: streamBaseUrl + '/asteroid-low.mp3',
type: 'audio/mpeg'
}
};
return config[encoding];
}
// Change stream quality
function changeStreamQuality() {
const selector = document.getElementById('stream-quality');
const streamBaseUrl = document.getElementById('stream-base-url').value;
const config = getStreamConfig(streamBaseUrl, selector.value);
const audioElement = document.getElementById('persistent-audio');
const sourceElement = document.getElementById('audio-source');
const wasPlaying = !audioElement.paused;
sourceElement.src = config.url;
sourceElement.type = config.type;
audioElement.load();
if (wasPlaying) {
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
}
}
// Update mini now playing display
async function updateMiniNowPlaying() {
try {
const response = await fetch('/api/asteroid/partial/now-playing-inline');
if (response.ok) {
const text = await response.text();
document.getElementById('mini-now-playing').textContent = text;
}
} catch(error) {
console.log('Could not fetch now playing:', error);
}
}
// Update every 10 seconds
setTimeout(updateMiniNowPlaying, 1000);
setInterval(updateMiniNowPlaying, 10000);
</script>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title lquery="(text title)">🎵 ASTEROID RADIO 🎵</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
// Prevent nested framesets - break out if we're already in a frame
if (window.self !== window.top) {
window.top.location.href = window.self.location.href;
}
</script>
</head>
<frameset rows="*,80" frameborder="0" border="0" framespacing="0">
<frame src="/asteroid/content" name="content-frame" noresize>
<frame src="/asteroid/audio-player-frame" name="player-frame" noresize scrolling="no">
<noframes>
<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>
</noframes>
</frameset>
</html>

View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title data-text="title">🎵 ASTEROID RADIO 🎵</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">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
<nav class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/status" target="content-frame">Status</a>
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main>
<div class="status">
<h2>Station Status</h2>
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
<p>Current listeners: <span data-text="listeners">0</span></p>
<p>Stream quality: <span data-text="stream-quality">AAC 96kbps Stereo</span></p>
</div>
<div class="live-stream">
<h2 style="color: #00ff00;">🟢 LIVE STREAM</h2>
<p><em>The live stream player is now in the persistent bar at the bottom of the page.</em></p>
<p><strong>Stream URL:</strong> <code id="stream-url" lquery="(text default-stream-url)"></code></p>
<p><strong>Format:</strong> <span id="stream-format" lquery="(text default-stream-encoding-desc)"></span></p>
<p><strong>Status:</strong> <span id="stream-status" style="color: #00ff00;">● BROADCASTING</span></p>
</div>
<div id="now-playing" class="now-playing"></div>
</main>
</div>
</body>
</html>

View File

@ -11,10 +11,10 @@
<header>
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/register">Register</a>
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/status" target="content-frame">Status</a>
<a href="/asteroid/register" target="content-frame">Register</a>
</nav>
</header>
@ -37,11 +37,6 @@
<button type="submit" class="btn btn-primary" style="width: 100%;">LOGIN</button>
</div>
</form>
<div class="panel" style="margin-top: 20px; text-align: center;">
<strong style="color: #ff6600;">Default Admin Credentials:</strong><br>
Username: <br><code style="color: #00ff00;">admin</code><br>
Password: <br><code style="color: #00ff00;">asteroid123</code>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title data-text="title">Asteroid Radio - Web 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">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/player.js"></script>
</head>
<body>
<div class="container">
<h1>🎵 WEB PLAYER</h1>
<div class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</div>
<!-- Live Stream Section - Note about persistent player -->
<div class="player-section">
<h2 style="color: #00ff00;">🟢 Live Radio Stream</h2>
<p><em>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!</em></p>
</div>
<div id="now-playing" class="now-playing"></div>
<!-- Track Browser -->
<div class="player-section">
<h2>Personal Track Library</h2>
<div class="track-browser">
<input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input">
<select id="library-tracks-per-page" class="sort-select" onchange="changeLibraryTracksPerPage()" style="margin: 10px 0px;">
<option value="10">10 per page</option>
<option value="20" selected>20 per page</option>
<option value="50">50 per page</option>
</select>
<div id="track-list" class="track-list">
<div class="loading">Loading tracks...</div>
</div>
<!-- Pagination Controls -->
<div id="library-pagination-controls" style="display: none; margin-top: 20px; text-align: center;">
<button onclick="libraryGoToPage(1)" class="btn btn-secondary">« First</button>
<button onclick="libraryPreviousPage()" class="btn btn-secondary"> Prev</button>
<span id="library-page-info" style="margin: 0 15px; font-weight: bold;">Page 1 of 1</span>
<button onclick="libraryNextPage()" class="btn btn-secondary">Next </button>
<button onclick="libraryGoToLastPage()" class="btn btn-secondary">Last »</button>
</div>
</div>
</div>
<!-- Audio Player Widget -->
<div class="player-section">
<h2>Audio Player</h2>
<div class="audio-player">
<div class="now-playing">
<div class="track-art">🎵</div>
<div class="track-details">
<div class="track-title" id="current-title">No track selected</div>
<div class="track-artist" id="current-artist">Unknown Artist</div>
<div class="track-album" id="current-album">Unknown Album</div>
</div>
</div>
<audio id="audio-player" controls preload="none" style="width: 100%; margin: 20px 0;">
Your browser does not support the audio element.
</audio>
<div class="player-controls">
<button id="prev-btn" class="btn btn-secondary">⏮️ Previous</button>
<button id="play-pause-btn" class="btn btn-primary">▶️ Play</button>
<button id="next-btn" class="btn btn-secondary">⏭️ Next</button>
<button id="shuffle-btn" class="btn btn-info">🔀 Shuffle</button>
<button id="repeat-btn" class="btn btn-warning">🔁 Repeat</button>
</div>
<div class="player-info">
<div class="time-display">
<span id="current-time">0:00</span> / <span id="total-time">0:00</span>
</div>
<div class="volume-control">
<label for="volume-slider">🔊</label>
<input type="range" id="volume-slider" min="0" max="100" value="50" class="volume-slider">
</div>
</div>
</div>
</div>
<!-- Playlist Management -->
<div class="player-section">
<h2>Playlists</h2>
<div class="playlist-controls">
<input type="text" id="new-playlist-name" placeholder="New playlist name..." class="playlist-input">
<button id="create-playlist" class="btn btn-success"> Create Playlist</button>
</div>
<div class="playlist-list">
<div id="playlists-container">
<div class="no-playlists">No playlists created yet.</div>
</div>
</div>
</div>
<!-- Queue -->
<div class="player-section">
<h2>Play Queue</h2>
<div class="queue-controls">
<button id="clear-queue" class="btn btn-danger">🗑️ Clear Queue</button>
<button id="save-queue" class="btn btn-info">💾 Save as Playlist</button>
</div>
<div id="play-queue" class="play-queue">
<div class="empty-queue">Queue is empty</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -12,10 +12,10 @@
<div class="container">
<h1>👤 USER PROFILE</h1>
<div class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player/">Player</a>
<a href="/asteroid/admin/" data-show-if-admin>Admin</a>
<a href="/asteroid/logout" class="btn-logout">Logout</a>
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
<a href="/asteroid/logout" target="_top" class="btn-logout">Logout</a>
</div>
<!-- User Profile Header -->

View File

@ -11,10 +11,10 @@
<header>
<h1>🎵 ASTEROID RADIO - REGISTER</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/login">Login</a>
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/status" target="content-frame">Status</a>
<a href="/asteroid/login" target="content-frame">Login</a>
</nav>
</header>

View File

@ -11,9 +11,9 @@
<div class="container">
<h1>👥 USER MANAGEMENT</h1>
<div class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/admin">Admin</a>
<a href="/asteroid/logout" class="btn-logout">Logout</a>
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/admin" target="content-frame">Admin</a>
<a href="/asteroid/logout" target="_top" class="btn-logout">Logout</a>
</div>
<!-- User Statistics -->