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

<!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>