You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
527 lines
16 KiB
527 lines
16 KiB
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|
<title>HushPupPi Radio</title> |
|
|
|
<!-- Reuse your existing site stylesheet --> |
|
<link rel="stylesheet" href="/simple.css"> |
|
|
|
<!-- Radio-specific styles (scoped and minimal) --> |
|
<style> |
|
/* Scope everything under .radio-app to avoid collisions */ |
|
.radio-app { |
|
display: grid; |
|
grid-template-columns: 1fr 360px; |
|
gap: 18px; |
|
padding: 18px; |
|
align-items: start; |
|
max-width: 1200px; |
|
margin: 0 auto 3rem auto; |
|
} |
|
|
|
body.radio-wide { |
|
grid-template-columns: 1fr min(80vw, 1200px) 1fr; |
|
} |
|
|
|
body.radio-wide > main { |
|
grid-column: 2; /* keep main in the center column */ |
|
width: 100%; /* let main fill that column */ |
|
max-width: none; /* ignore any max-width inherited from other rules */ |
|
padding-left: 0.5rem; /* optional: small inner padding */ |
|
padding-right: 0.5rem; /* optional: small inner padding */ |
|
} |
|
|
|
/* Strongly override the global aside width for the radio UI only */ |
|
body.radio-wide aside, |
|
body.radio-wide .radio-side, |
|
.radio-app aside { |
|
width: auto !important; |
|
min-width: 260px; |
|
max-width: 420px; |
|
box-sizing: border-box; |
|
} |
|
|
|
/* Make sure the main column can take remaining space */ |
|
body.radio-wide main > .radio-app, |
|
body.radio-wide .radio-app { |
|
grid-column: 2; |
|
width: 100%; |
|
max-width: none; |
|
} |
|
|
|
/* Keep small-screen behavior intact */ |
|
@media (max-width: 880px) { |
|
body.radio-wide aside, |
|
body.radio-wide .radio-side, |
|
.radio-app aside { |
|
width: 100% !important; |
|
min-width: 0; |
|
max-width: none; |
|
} |
|
} |
|
|
|
.radio-app { |
|
width: 100%; /* fill the main column */ |
|
max-width: none; /* don't cap at previous value */ |
|
margin: 0 auto 3rem auto; |
|
} |
|
|
|
@media (max-width: 880px) { |
|
body.radio-wide > main { width: calc(100% - 32px); } |
|
.radio-app { width: 100%; } |
|
} |
|
|
|
/* Card base (reuses simple.css variables) */ |
|
.radio-card { |
|
background: var(--bg); |
|
border-radius: 8px; |
|
padding: 12px; |
|
box-shadow: 0 1px 6px rgba(0,0,0,0.06); |
|
border: 1px solid rgba(0,0,0,0.04); |
|
} |
|
|
|
/* Left column: library */ |
|
.radio-library { min-height: 520px; } |
|
.radio-header { |
|
display:flex; |
|
align-items:center; |
|
justify-content:space-between; |
|
gap:12px; |
|
margin-bottom: 8px; |
|
} |
|
.radio-title { font-size: 1.15rem; font-weight: 600; } |
|
|
|
.radio-controls { display:flex; gap:8px; align-items:center; } |
|
|
|
/* Search */ |
|
.radio-search { width: 100%; margin: 8px 0 12px 0; display:flex; gap:8px; } |
|
.radio-search input[type="search"] { |
|
flex:1; |
|
padding:8px 10px; |
|
border-radius:6px; |
|
border:1px solid #ddd; |
|
background:var(--bg); |
|
color:var(--text); |
|
} |
|
|
|
/* Track list */ |
|
.track-list { |
|
list-style:none; |
|
margin:0; |
|
padding:0; |
|
max-height: 56vh; |
|
overflow:auto; |
|
border-top: 1px solid rgba(0,0,0,0.04); |
|
} |
|
.track-item { |
|
display:flex; |
|
align-items:center; |
|
gap:10px; |
|
padding:8px; |
|
border-bottom: 1px solid rgba(0,0,0,0.03); |
|
cursor: pointer; |
|
} |
|
.track-item:hover { background: rgba(0,0,0,0.02); } |
|
|
|
.track-thumb { |
|
width:44px; |
|
height:44px; |
|
object-fit:cover; |
|
border-radius:4px; |
|
flex: 0 0 44px; |
|
background: linear-gradient(90deg, rgba(0,0,0,0.03), rgba(0,0,0,0.01)); |
|
} |
|
|
|
.track-meta { |
|
display:flex; |
|
flex-direction:column; |
|
min-width:0; |
|
} |
|
.track-name { |
|
font-size: 0.98rem; |
|
white-space:nowrap; |
|
overflow:hidden; |
|
text-overflow:ellipsis; |
|
color:var(--text); |
|
} |
|
.track-sub { |
|
font-size: 0.86rem; |
|
color:var(--text-light); |
|
} |
|
|
|
/* Right column: queue & now playing */ |
|
.radio-side { position:relative; min-height:520px; } |
|
.now-playing { |
|
display:flex; |
|
gap:12px; |
|
align-items:center; |
|
margin-bottom:12px; |
|
} |
|
.now-thumb { |
|
width:84px; |
|
height:84px; |
|
object-fit:cover; |
|
border-radius:6px; |
|
background: linear-gradient(90deg, rgba(0,0,0,0.03), rgba(0,0,0,0.01)); |
|
} |
|
.now-meta { display:flex; flex-direction:column; gap:6px; min-width:0; } |
|
.now-title { font-weight:600; font-size:1rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } |
|
.now-sub { color:var(--text-light); font-size:0.9rem; } |
|
|
|
.queue-list { list-style:none; margin:0; padding:0; max-height:46vh; overflow:auto; border-top:1px solid rgba(0,0,0,0.04); } |
|
.queue-item { display:flex; gap:8px; align-items:center; padding:8px; border-bottom:1px solid rgba(0,0,0,0.03); } |
|
.queue-item .qi-name { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } |
|
|
|
/* Small controls row */ |
|
.controls-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; } |
|
.btn-ghost { |
|
background: transparent; |
|
color: var(--accent); |
|
border: 1px solid var(--accent); |
|
padding: 0.45rem 0.6rem; |
|
border-radius:6px; |
|
} |
|
.btn-small { padding: 0.35rem 0.6rem; font-size:0.95rem; } |
|
|
|
/* Responsive */ |
|
@media (max-width: 880px) { |
|
.radio-app { grid-template-columns: 1fr; } |
|
.radio-side { order: 2; } |
|
} |
|
</style> |
|
</head> |
|
<body class="radio-wide"> |
|
<header> |
|
<h1>HushPupPi Radio</h1> |
|
<p>Welcome to HushPupPi Pirate Radio. Click a song to queue it.</p> |
|
</header> |
|
|
|
<main class="radio-app"> |
|
<!-- Library --> |
|
<section class="radio-card radio-library" aria-labelledby="lib-title"> |
|
<div class="radio-header"> |
|
<div> |
|
<div id="lib-title" class="radio-title">Library</div> |
|
<div class="track-sub">Browse tracks and add to queue</div> |
|
</div> |
|
<div class="radio-controls"> |
|
<button id="refreshTracks">Refresh</button> |
|
</div> |
|
</div> |
|
|
|
<div class="radio-search"> |
|
<input id="searchInput" type="search" placeholder="Filter tracks (title, album, artist)"/> |
|
<button id="clearSearch" class="btn-ghost btn-small">Clear</button> |
|
</div> |
|
|
|
<ul id="tracks" class="track-list" aria-live="polite"></ul> |
|
</section> |
|
|
|
<!-- Sidebar: Now + Queue --> |
|
<aside class="radio-card radio-side" aria-labelledby="now-title"> |
|
<div class="now-playing"> |
|
<img id="nowThumb" class="now-thumb" src="" alt="" style="display:none" /> |
|
<div class="now-meta"> |
|
<div id="now-title" class="now-title">Not playing</div> |
|
<div id="now-sub" class="now-sub">Queue is empty</div> |
|
<div class="controls-row" style="margin-top:6px;"> |
|
<button id="btnSkip">Skip</button> |
|
<button id="btnStop">Stop</button> |
|
<button id="btnShuffle" class="btn-ghost btn-small">Shuffle</button> |
|
<button id="btnRepeat" class="btn-ghost btn-small">Repeat: off</button> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<h4 style="margin:8px 0 6px 0">Queue</h4> |
|
<ul id="queue" class="queue-list" aria-live="polite"></ul> |
|
|
|
<div style="margin-top:12px; font-size:0.9rem; color:var(--text-light);"> |
|
<div><strong>Tip</strong>: Click a track to queue it. Use Skip to advance immediately.</div> |
|
</div> |
|
</aside> |
|
</main> |
|
|
|
<footer> |
|
<small>HushPupPi Radio — lightweight FM streaming control panel</small> |
|
</footer> |
|
|
|
<!-- Minimal JS: fetch tracks, queue, now; wire controls --> |
|
<script> |
|
/* --- API endpoints (keep /api/ prefix for your proxy) --- */ |
|
const API = { |
|
tracks: "/api/tracks", |
|
queue: "/api/queue", |
|
now: "/api/now", |
|
album_art: (name) => `/api/album_art/${encodeURIComponent(name)}`, |
|
add_queue: "/api/queue", |
|
skip: "/api/skip", |
|
stop: "/api/stop", |
|
shuffle: "/api/shuffle", |
|
repeat: "/api/repeat" |
|
}; |
|
|
|
let allTracks = []; // array of {path, filename, duration, album_art} |
|
let currentShuffle = false; |
|
let currentRepeat = "off"; |
|
|
|
/* --- helpers --- */ |
|
function fmtDuration(s) { |
|
if (!s && s !== 0) return ""; |
|
const m = Math.floor(s / 60); |
|
const sec = Math.floor(s % 60).toString().padStart(2, "0"); |
|
return `${m}:${sec}`; |
|
} |
|
|
|
/* Normalize a server track object into the shape the UI expects: |
|
{ id, path, filename, duration, album_art } */ |
|
function normalizeTrack(item) { |
|
// Try common keys; add more fallbacks if your backend uses different names |
|
const path = item.path || item.id || item.file || item.relpath || item.filename; |
|
const filename = item.filename || (typeof path === "string" ? path.split("/").pop() : null); |
|
const duration = item.duration || item.length || item.seconds || null; |
|
const album_art = item.album_art || item.art || item.cover || null; |
|
return { id: path, path, filename, duration, album_art, raw: item }; |
|
} |
|
|
|
/* Render the track list robustly */ |
|
function renderTracks(list) { |
|
const ul = document.getElementById("tracks"); |
|
ul.innerHTML = ""; |
|
const q = document.getElementById("searchInput").value.trim().toLowerCase(); |
|
|
|
list.forEach(rawItem => { |
|
const item = normalizeTrack(rawItem); |
|
if (!item.path) { |
|
// Skip items we can't identify; log for debugging |
|
console.warn("Skipping track with no path/id:", rawItem); |
|
return; |
|
} |
|
|
|
const displayName = item.path; // use relative path as the canonical name |
|
if (q && !displayName.toLowerCase().includes(q) && !(item.filename && item.filename.toLowerCase().includes(q))) { |
|
return; |
|
} |
|
|
|
const li = document.createElement("li"); |
|
li.className = "track-item"; |
|
li.title = displayName; |
|
|
|
if (item.album_art) { |
|
const img = document.createElement("img"); |
|
img.className = "track-thumb"; |
|
img.src = API.album_art(item.album_art); |
|
img.alt = ""; |
|
img.onerror = () => img.style.display = "none"; |
|
li.appendChild(img); |
|
} else { |
|
const placeholder = document.createElement("div"); |
|
placeholder.className = "track-thumb"; |
|
li.appendChild(placeholder); |
|
} |
|
|
|
const meta = document.createElement("div"); |
|
meta.className = "track-meta"; |
|
|
|
const tname = document.createElement("div"); |
|
tname.className = "track-name"; |
|
tname.textContent = item.filename || displayName; |
|
meta.appendChild(tname); |
|
|
|
const sub = document.createElement("div"); |
|
sub.className = "track-sub"; |
|
sub.textContent = item.duration ? fmtDuration(item.duration) : ""; |
|
meta.appendChild(sub); |
|
|
|
li.appendChild(meta); |
|
|
|
// Queue the canonical relative path (item.path) — backend expects that |
|
li.onclick = () => queueTrack(item.path); |
|
|
|
ul.appendChild(li); |
|
}); |
|
} |
|
|
|
/* Load tracks and log raw response for debugging */ |
|
async function loadTracks() { |
|
try { |
|
const res = await fetch(API.tracks); |
|
if (!res.ok) { |
|
console.error("Failed to fetch tracks:", res.status, res.statusText); |
|
document.getElementById("tracks").innerHTML = "<li>Error loading tracks</li>"; |
|
return; |
|
} |
|
const data = await res.json(); |
|
console.log("DEBUG: /api/tracks response:", data); |
|
// If the server returns an object with a list property, adapt here: |
|
const list = Array.isArray(data) ? data : (Array.isArray(data.tracks) ? data.tracks : []); |
|
renderTracks(list); |
|
} catch (e) { |
|
console.error("Failed to load tracks", e); |
|
document.getElementById("tracks").innerHTML = "<li>Error loading tracks</li>"; |
|
} |
|
} |
|
|
|
/* Queue a track by sending the exact relative path the backend expects */ |
|
async function queueTrack(trackPath) { |
|
try { |
|
// Defensive: ensure we send a string and not an object |
|
const payload = { track: String(trackPath) }; |
|
const res = await fetch(API.add_queue, { |
|
method: "POST", |
|
headers: {"Content-Type":"application/json"}, |
|
body: JSON.stringify(payload) |
|
}); |
|
if (!res.ok) { |
|
console.error("Failed to queue track:", res.status, await res.text()); |
|
} |
|
// Refresh queue/now after a short delay |
|
setTimeout(refresh, 250); |
|
} catch (e) { |
|
console.error("Failed to queue", e); |
|
} |
|
} |
|
|
|
// Utilities |
|
function fmtDuration(s) { |
|
if (!s && s !== 0) return ""; |
|
const m = Math.floor(s / 60); |
|
const sec = Math.floor(s % 60).toString().padStart(2, "0"); |
|
return `${m}:${sec}`; |
|
} |
|
|
|
// Render functions |
|
async function loadQueue() { |
|
try { |
|
const res = await fetch(API.queue); |
|
const q = await res.json(); |
|
const ul = document.getElementById("queue"); |
|
ul.innerHTML = ""; |
|
q.forEach((t, idx) => { |
|
const li = document.createElement("li"); |
|
li.className = "queue-item"; |
|
const name = document.createElement("div"); |
|
name.className = "qi-name"; |
|
name.textContent = t; |
|
li.appendChild(name); |
|
|
|
const btn = document.createElement("button"); |
|
btn.textContent = "Play Next"; |
|
btn.className = "btn-ghost btn-small"; |
|
btn.onclick = () => { |
|
// Play next: insert at position 1 (after current) |
|
fetch("/api/queue", { |
|
method: "POST", |
|
headers: {"Content-Type":"application/json"}, |
|
body: JSON.stringify({ track: t }) |
|
}).then(refresh); |
|
}; |
|
li.appendChild(btn); |
|
|
|
ul.appendChild(li); |
|
}); |
|
} catch (e) { |
|
console.error("Failed to load queue", e); |
|
} |
|
} |
|
|
|
async function loadNow() { |
|
try { |
|
const res = await fetch(API.now); |
|
const now = await res.json(); |
|
const title = document.getElementById("now-title"); |
|
const sub = document.getElementById("now-sub"); |
|
const thumb = document.getElementById("nowThumb"); |
|
|
|
if (now && now.track) { |
|
title.textContent = now.track; |
|
sub.textContent = "Now playing"; |
|
if (now.album_art) { |
|
thumb.src = now.album_art; |
|
thumb.style.display = ""; |
|
} else { |
|
thumb.style.display = "none"; |
|
} |
|
} else { |
|
title.textContent = "Not playing"; |
|
sub.textContent = "Queue is empty"; |
|
thumb.style.display = "none"; |
|
} |
|
} catch (e) { |
|
console.error("Failed to load now", e); |
|
} |
|
} |
|
|
|
// Actions |
|
async function queueTrack(track) { |
|
try { |
|
await fetch(API.add_queue, { |
|
method: "POST", |
|
headers: {"Content-Type":"application/json"}, |
|
body: JSON.stringify({ track }) |
|
}); |
|
refresh(); |
|
} catch (e) { |
|
console.error("Failed to queue", e); |
|
} |
|
} |
|
|
|
async function skip() { |
|
await fetch(API.skip, { method: "POST" }); |
|
setTimeout(refresh, 300); |
|
} |
|
async function stop() { |
|
await fetch(API.stop, { method: "POST" }); |
|
setTimeout(refresh, 300); |
|
} |
|
async function toggleShuffle() { |
|
currentShuffle = !currentShuffle; |
|
await fetch(API.shuffle, { |
|
method: "POST", |
|
headers: {"Content-Type":"application/json"}, |
|
body: JSON.stringify({ enabled: currentShuffle }) |
|
}); |
|
document.getElementById("btnShuffle").textContent = currentShuffle ? "Shuffle On" : "Shuffle"; |
|
} |
|
async function cycleRepeat() { |
|
const order = ["off", "repeat_one", "repeat_all"]; |
|
const next = order[(order.indexOf(currentRepeat) + 1) % order.length]; |
|
currentRepeat = next; |
|
await fetch(API.repeat, { |
|
method: "POST", |
|
headers: {"Content-Type":"application/json"}, |
|
body: JSON.stringify({ mode: currentRepeat }) |
|
}); |
|
document.getElementById("btnRepeat").textContent = "Repeat: " + currentRepeat; |
|
} |
|
|
|
// Refresh everything |
|
async function refresh() { |
|
await Promise.all([loadQueue(), loadNow()]); |
|
} |
|
|
|
// Wire UI |
|
document.getElementById("refreshTracks").addEventListener("click", loadTracks); |
|
document.getElementById("clearSearch").addEventListener("click", () => { |
|
document.getElementById("searchInput").value = ""; |
|
renderTracks(allTracks); |
|
}); |
|
document.getElementById("searchInput").addEventListener("input", () => renderTracks(allTracks)); |
|
document.getElementById("btnSkip").addEventListener("click", skip); |
|
document.getElementById("btnStop").addEventListener("click", stop); |
|
document.getElementById("btnShuffle").addEventListener("click", toggleShuffle); |
|
document.getElementById("btnRepeat").addEventListener("click", cycleRepeat); |
|
|
|
// Initial load and polling |
|
(async function init() { |
|
await loadTracks(); |
|
await refresh(); |
|
// Poll every 5s for now/queue updates |
|
setInterval(refresh, 5000); |
|
})(); |
|
</script> |
|
</body> |
|
</html> |
|
|
|
|