Initial commit: self-hosted personal radio station

Flask + React web UI with audio player, podcast queue, feed management,
episode browser, music library, schedule viewer, and log tail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 19:01:33 -07:00
co-authored by Claude Opus 4.6
commit 3d635b742c
53 changed files with 7023 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { api } from "../api";
function formatTime(s) {
if (!s || !isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}
export default function AudioPlayer() {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState(0.8);
const [muted, setMuted] = useState(false);
const [error, setError] = useState(false);
const [track, setTrack] = useState(null);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [seeking, setSeeking] = useState(false);
const activeRef = useRef(false); // whether the player should be running
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume;
}, [volume]);
// Time update for progress bar
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTime = () => { if (!seeking) setProgress(audio.currentTime); };
const onDur = () => setDuration(audio.duration);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("durationchange", onDur);
return () => {
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("durationchange", onDur);
};
}, [seeking]);
const loadAndPlay = useCallback(async () => {
const audio = audioRef.current;
if (!audio || !activeRef.current) return;
setError(false);
try {
const next = await api.getNextTrack();
setTrack(next);
audio.src = `/api/stream/play/${next.id}`;
audio.load();
await audio.play();
setPlaying(true);
} catch {
setError(true);
setPlaying(false);
// Retry after a pause
if (activeRef.current) setTimeout(loadAndPlay, 3000);
}
}, []);
// When track ends, load next
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onEnded = () => {
if (track?.source === "queue" && track?.id) {
api.markDone(track.id).catch(() => {});
}
loadAndPlay();
};
audio.addEventListener("ended", onEnded);
return () => audio.removeEventListener("ended", onEnded);
}, [track, loadAndPlay]);
const toggle = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
activeRef.current = false;
} else {
activeRef.current = true;
if (audio.src && audio.src !== window.location.href) {
audio.play().catch(() => {});
setPlaying(true);
} else {
loadAndPlay();
}
}
};
const skip = () => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
if (track?.source === "queue") {
api.skip().catch(() => {});
}
loadAndPlay();
};
const handleSeek = (e) => {
const val = parseFloat(e.target.value);
setProgress(val);
if (audioRef.current) audioRef.current.currentTime = val;
setSeeking(false);
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !muted;
setMuted(!muted);
}
};
return (
<div className="fixed bottom-0 left-0 right-0 bg-radio-card border-t border-radio-border z-50">
{/* Progress bar */}
{playing && duration > 0 && (
<div className="px-4 pt-1 flex items-center gap-2 text-xs text-radio-muted">
<span className="w-10 text-right">{formatTime(progress)}</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={progress}
onMouseDown={() => setSeeking(true)}
onTouchStart={() => setSeeking(true)}
onChange={handleSeek}
className="flex-1 accent-radio-accent h-1"
/>
<span className="w-10">{formatTime(duration)}</span>
</div>
)}
<div className="px-4 py-2 flex items-center gap-4">
<audio ref={audioRef} preload="auto" />
{/* Play/Pause */}
<button onClick={toggle} className="btn btn-primary w-10 h-10 flex items-center justify-center rounded-full p-0">
{playing ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
{/* Skip */}
<button onClick={skip} className="btn-ghost p-1 rounded" title="Skip track">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5,4 15,12 5,20" />
<rect x="15" y="4" width="4" height="16" />
</svg>
</button>
{/* Equalizer / Status */}
<div className="flex items-end gap-0.5 h-5">
{playing && !error ? (
<>
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
</>
) : (
<span className="text-xs text-radio-muted">
{error ? "Stream unavailable" : "Stopped"}
</span>
)}
</div>
{/* Now Playing */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{track?.title || "Local Radio"}
</div>
<div className="text-xs text-radio-muted">
{playing
? track?.source === "queue" ? "Podcast" : "On Air"
: error ? "Could not connect" : "Click play to listen"}
</div>
</div>
{/* Volume */}
<button onClick={toggleMute} className="btn-ghost p-1 rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
{muted ? (
<path d="M11 5L6 9H2v6h4l5 4V5zM23 9l-6 6M17 9l6 6" />
) : (
<path d="M11 5L6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" />
)}
</svg>
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => {
setVolume(parseFloat(e.target.value));
if (muted) setMuted(false);
}}
className="w-24 accent-radio-accent"
/>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect } from "react";
import { useOutletContext, Link } from "react-router-dom";
import { api } from "../api";
function StatCard({ label, value, sub, color = "text-radio-text" }) {
return (
<div className="card">
<div className="text-xs text-radio-muted uppercase tracking-wide">{label}</div>
<div className={`text-2xl font-bold mt-1 ${color}`}>{value}</div>
{sub && <div className="text-xs text-radio-muted mt-1">{sub}</div>}
</div>
);
}
export default function Dashboard() {
const { status } = useOutletContext();
const [queue, setQueue] = useState([]);
const [recentEpisodes, setRecentEpisodes] = useState([]);
useEffect(() => {
api.getQueue().then(setQueue).catch(() => {});
api.getEpisodes({ per_page: 5 }).then((d) => setRecentEpisodes(d.episodes)).catch(() => {});
}, []);
const stream = status?.stream || {};
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{/* Now Playing */}
<div className="card mb-6 border-radio-accent/30">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${stream.online ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
<div className="flex-1 min-w-0">
<div className="text-xs text-radio-muted uppercase tracking-wide">Now Playing</div>
<div className="text-lg font-semibold truncate mt-0.5">
{stream.now_playing || "Nothing playing"}
</div>
</div>
{status?.current_block && (
<div className="badge badge-yellow">
{status.current_block.name}
</div>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard
label="Stream"
value={stream.online ? "Online" : "Offline"}
color={stream.online ? "text-radio-success" : "text-radio-danger"}
/>
<StatCard label="Tracks" value={stream.tracks ?? 0} />
<StatCard label="Queue Depth" value={status?.queue_depth ?? 0} />
<StatCard
label="Episodes"
value={status?.total_episodes ?? 0}
sub={`${status?.active_feeds ?? 0} feeds`}
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Queue Preview */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">Podcast Queue</h2>
<Link to="/queue" className="text-xs text-radio-accent hover:text-yellow-400">
View all
</Link>
</div>
{queue.length === 0 ? (
<p className="text-sm text-radio-muted">Queue empty — music is playing</p>
) : (
<ul className="space-y-2">
{queue.slice(0, 5).map((item) => (
<li key={item.id} className="flex items-center gap-2 text-sm">
<span className="text-radio-accent font-mono text-xs w-6">#{item.position}</span>
<span className="truncate">{item.title}</span>
<span className="text-radio-muted text-xs ml-auto flex-shrink-0">{item.feed_name}</span>
</li>
))}
{queue.length > 5 && (
<li className="text-xs text-radio-muted">+{queue.length - 5} more</li>
)}
</ul>
)}
</div>
{/* Recent Episodes */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">Recent Episodes</h2>
<Link to="/episodes" className="text-xs text-radio-accent hover:text-yellow-400">
View all
</Link>
</div>
{recentEpisodes.length === 0 ? (
<p className="text-sm text-radio-muted">No episodes yet</p>
) : (
<ul className="space-y-2">
{recentEpisodes.map((ep) => (
<li key={ep.id} className="text-sm">
<div className="flex items-center gap-2">
<span className="truncate">{ep.title}</span>
<div className="flex gap-1 ml-auto flex-shrink-0">
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
{ep.played ? <span className="badge badge-blue">P</span> : null}
</div>
</div>
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
import { useState, useEffect } from "react";
import { api } from "../api";
export default function EpisodesPanel() {
const [data, setData] = useState({ episodes: [], total: 0, page: 1, pages: 1 });
const [feedFilter, setFeedFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [feedNames, setFeedNames] = useState([]);
const [page, setPage] = useState(1);
const [enqueuing, setEnqueuing] = useState(null);
const handleEnqueue = async (ep) => {
if (!ep.downloaded) return;
setEnqueuing(ep.id);
try {
await api.enqueue({ episode_id: ep.id });
fetchEpisodes();
} catch (e) {
alert(e.message);
} finally {
setEnqueuing(null);
}
};
const fetchEpisodes = () => {
const params = { page, per_page: 20 };
if (feedFilter) params.feed = feedFilter;
if (statusFilter) params.status = statusFilter;
api.getEpisodes(params).then(setData).catch(() => {});
};
useEffect(() => {
api.getEpisodeFeeds().then(setFeedNames).catch(() => {});
}, []);
useEffect(fetchEpisodes, [page, feedFilter, statusFilter]);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Episodes</h1>
{/* Filters */}
<div className="flex gap-3 mb-4 flex-wrap">
<select
value={feedFilter}
onChange={(e) => { setFeedFilter(e.target.value); setPage(1); }}
className="input text-sm"
>
<option value="">All feeds</option>
{feedNames.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="input text-sm"
>
<option value="">All statuses</option>
<option value="downloaded">Downloaded</option>
<option value="queued">Queued</option>
<option value="played">Played</option>
</select>
<span className="text-sm text-radio-muted self-center ml-auto">
{data.total} episode{data.total !== 1 ? "s" : ""}
</span>
</div>
{/* Table */}
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-radio-muted text-xs uppercase border-b border-radio-border">
<th className="text-left py-2 px-2">Title</th>
<th className="text-left py-2 px-2">Feed</th>
<th className="text-left py-2 px-2">Published</th>
<th className="text-left py-2 px-2">Status</th>
</tr>
</thead>
<tbody>
{data.episodes.length === 0 ? (
<tr>
<td colSpan="4" className="text-center py-8 text-radio-muted">
No episodes found
</td>
</tr>
) : (
data.episodes.map((ep) => (
<tr
key={ep.id}
className={`border-b border-radio-border/50 hover:bg-radio-border/10 ${ep.downloaded ? "cursor-pointer" : ""}`}
onClick={() => handleEnqueue(ep)}
title={ep.downloaded ? (ep.queued ? "Already in queue" : "Click to add to queue") : ""}
>
<td className="py-2 px-2 max-w-xs truncate">{ep.title}</td>
<td className="py-2 px-2 text-radio-muted">{ep.feed_name}</td>
<td className="py-2 px-2 text-radio-muted whitespace-nowrap">
{ep.pub_date ? new Date(ep.pub_date).toLocaleDateString() : "-"}
</td>
<td className="py-2 px-2">
<div className="flex items-center gap-1">
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
{ep.played ? <span className="badge badge-blue">P</span> : null}
{ep.downloaded && !ep.queued && (
<button
className="ml-1 text-radio-accent hover:text-radio-accent/80 disabled:opacity-50"
disabled={enqueuing === ep.id}
onClick={(e) => { e.stopPropagation(); handleEnqueue(ep); }}
title="Add to queue"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
)}
{enqueuing === ep.id && (
<span className="text-xs text-radio-accent ml-1">Queuing...</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="btn btn-ghost text-sm disabled:opacity-30"
>
Previous
</button>
<span className="text-sm text-radio-muted">
Page {data.page} of {data.pages}
</span>
<button
disabled={page >= data.pages}
onClick={() => setPage(page + 1)}
className="btn btn-ghost text-sm disabled:opacity-30"
>
Next
</button>
</div>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useState, useEffect } from "react";
import { api } from "../api";
function AddFeedModal({ onClose, onAdded }) {
const [form, setForm] = useState({ name: "", url: "", priority: 10, max_episodes: 20 });
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.addFeed(form);
onAdded();
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="card w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<h2 className="font-semibold text-lg mb-4">Add Feed</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-radio-muted block mb-1">Name</label>
<input
className="input w-full"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="My Podcast"
required
/>
</div>
<div>
<label className="text-xs text-radio-muted block mb-1">RSS URL</label>
<input
className="input w-full"
type="url"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://feeds.example.com/podcast.xml"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-radio-muted block mb-1">Priority</label>
<input
className="input w-full"
type="number"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 10 })}
/>
</div>
<div>
<label className="text-xs text-radio-muted block mb-1">Max Episodes</label>
<input
className="input w-full"
type="number"
value={form.max_episodes}
onChange={(e) => setForm({ ...form, max_episodes: parseInt(e.target.value) || 20 })}
/>
</div>
</div>
{error && <div className="text-radio-danger text-sm">{error}</div>}
<div className="flex gap-2 pt-2">
<button type="submit" disabled={saving} className="btn btn-primary flex-1">
{saving ? "Adding..." : "Add Feed"}
</button>
<button type="button" onClick={onClose} className="btn btn-ghost flex-1">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function FeedsPanel() {
const [feeds, setFeeds] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [error, setError] = useState(null);
const refresh = () => api.getFeeds().then(setFeeds).catch((e) => setError(e.message));
useEffect(() => { refresh(); }, []);
const handleToggle = async (name, enabled) => {
try {
await api.updateFeed(name, { enabled: !enabled });
refresh();
} catch (e) {
setError(e.message);
}
};
const handleDelete = async (name) => {
if (!confirm(`Remove feed "${name}"? This won't delete downloaded episodes.`)) return;
try {
await api.deleteFeed(name);
refresh();
} catch (e) {
setError(e.message);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Feeds</h1>
<button onClick={() => setShowAdd(true)} className="btn btn-primary text-sm">
Add Feed
</button>
</div>
{error && (
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{feeds.length === 0 ? (
<div className="card text-center py-12">
<div className="text-radio-muted text-lg mb-2">No feeds configured</div>
<div className="text-sm text-radio-muted">Add an RSS feed to start receiving podcast episodes.</div>
</div>
) : (
<div className="space-y-3">
{feeds.map((feed) => (
<div key={feed.name} className="card flex items-center gap-4">
{/* Toggle */}
<button
onClick={() => handleToggle(feed.name, feed.enabled)}
className={`w-12 h-6 rounded-full flex items-center transition-colors flex-shrink-0 ${
feed.enabled ? "bg-radio-success" : "bg-radio-border"
}`}
>
<span
className={`w-5 h-5 rounded-full bg-white transition-transform shadow ${
feed.enabled ? "translate-x-6" : "translate-x-0.5"
}`}
/>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="font-medium">{feed.name}</div>
<div className="text-xs text-radio-muted truncate">{feed.url}</div>
<div className="flex gap-3 mt-1 text-xs text-radio-muted">
<span>{feed.episode_count} episodes</span>
<span>Priority: {feed.priority}</span>
{feed.last_poll && (
<span>Last poll: {new Date(feed.last_poll + "Z").toLocaleString()}</span>
)}
</div>
</div>
{/* Actions */}
<button
onClick={() => handleDelete(feed.name)}
className="btn btn-danger text-sm"
>
Remove
</button>
</div>
))}
</div>
)}
{showAdd && <AddFeedModal onClose={() => setShowAdd(false)} onAdded={refresh} />}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom";
import AudioPlayer from "./AudioPlayer";
import { api } from "../api";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
{ to: "/queue", label: "Queue", icon: "M4 6h16M4 10h16M4 14h16M4 18h16" },
{ to: "/episodes", label: "Episodes", icon: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" },
{ to: "/feeds", label: "Feeds", icon: "M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" },
{ to: "/schedule", label: "Schedule", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" },
{ to: "/music", label: "Music", icon: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" },
{ to: "/logs", label: "Logs", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" },
];
export default function Layout() {
const [status, setStatus] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => {
const poll = () => api.getStatus().then(setStatus).catch(() => {});
poll();
const id = setInterval(poll, 10000);
return () => clearInterval(id);
}, []);
const streamOnline = status?.stream?.online;
const nowPlaying = status?.stream?.now_playing;
return (
<div className="flex h-screen pb-16">
{/* Sidebar */}
<aside className={`${sidebarOpen ? "w-56" : "w-16"} flex-shrink-0 bg-radio-card border-r border-radio-border flex flex-col transition-all duration-200`}>
{/* Logo / Header */}
<div className="p-4 border-b border-radio-border flex items-center gap-3">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-radio-accent hover:text-yellow-400 transition-colors">
<svg className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 12h.01M8.464 15.536a5 5 0 010-7.072m7.072 0a5 5 0 010 7.072" />
</svg>
</button>
{sidebarOpen && (
<div>
<div className="font-bold text-radio-accent text-sm">LOCAL RADIO</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-2 h-2 rounded-full ${streamOnline ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
<span className="text-xs text-radio-muted">{streamOnline ? "On Air" : "Offline"}</span>
</div>
</div>
)}
</div>
{/* Nav */}
<nav className="flex-1 py-2">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? "text-radio-accent bg-radio-accent/10 border-r-2 border-radio-accent"
: "text-radio-muted hover:text-radio-text hover:bg-radio-border/20"
}`
}
>
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
</svg>
{sidebarOpen && <span>{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet context={{ status }} />
</main>
{/* Persistent audio player */}
<AudioPlayer />
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState, useEffect, useRef } from "react";
import { api } from "../api";
const LOG_FILES = ["poller", "liquidsoap", "icecast_access", "icecast_error"];
export default function LogsPanel() {
const [logFile, setLogFile] = useState("poller");
const [lines, setLines] = useState([]);
const [lineCount, setLineCount] = useState(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [exists, setExists] = useState(true);
const bottomRef = useRef(null);
const fetchLogs = () => {
api.getLogs({ file: logFile, lines: lineCount })
.then((data) => {
setLines(data.lines);
setExists(data.exists);
})
.catch(() => {});
};
useEffect(fetchLogs, [logFile, lineCount]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(fetchLogs, 5000);
return () => clearInterval(id);
}, [autoRefresh, logFile, lineCount]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [lines]);
return (
<div>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<h1 className="text-2xl font-bold">Logs</h1>
<div className="flex gap-3 items-center">
<select
value={logFile}
onChange={(e) => setLogFile(e.target.value)}
className="input text-sm"
>
{LOG_FILES.map((f) => (
<option key={f} value={f}>{f}.log</option>
))}
</select>
<select
value={lineCount}
onChange={(e) => setLineCount(parseInt(e.target.value))}
className="input text-sm"
>
<option value={50}>50 lines</option>
<option value={100}>100 lines</option>
<option value={200}>200 lines</option>
</select>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="accent-radio-accent"
/>
Auto-refresh
</label>
<button onClick={fetchLogs} className="btn btn-ghost text-sm">
Refresh
</button>
</div>
</div>
<div className="card p-0">
{!exists ? (
<div className="p-8 text-center text-radio-muted">
Log file <code>{logFile}.log</code> does not exist yet
</div>
) : lines.length === 0 ? (
<div className="p-8 text-center text-radio-muted">
Log file is empty
</div>
) : (
<pre className="text-xs font-mono overflow-x-auto p-4 max-h-[70vh] overflow-y-auto leading-relaxed">
{lines.map((line, i) => (
<div
key={i}
className={`py-0.5 ${
line.includes("ERROR") || line.includes("error")
? "text-radio-danger"
: line.includes("WARNING") || line.includes("warning")
? "text-yellow-400"
: "text-radio-muted"
}`}
>
{line}
</div>
))}
<div ref={bottomRef} />
</pre>
)}
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useState, useEffect } from "react";
import { api } from "../api";
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function FolderCard({ folder, name, path, count, isFallback, allFolders, onRefresh }) {
const [expanded, setExpanded] = useState(false);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [moving, setMoving] = useState(null);
const [error, setError] = useState(null);
const loadFiles = () => {
setLoading(true);
api.getMusicFiles(folder).then((d) => {
setFiles(d.files);
setLoading(false);
}).catch(() => setLoading(false));
};
const toggle = () => {
if (!expanded) loadFiles();
setExpanded(!expanded);
};
const handleMove = async (filename, destFolder) => {
setMoving(filename);
setError(null);
try {
await api.moveFile(filename, folder, destFolder);
loadFiles();
onRefresh();
} catch (e) {
setError(`${filename}: ${e.message}`);
} finally {
setMoving(null);
}
};
const destinations = allFolders.filter((f) => f !== folder);
return (
<div className={`card ${isFallback ? (count === 0 ? "border-radio-danger/50" : "border-radio-success/30") : ""}`}>
<button onClick={toggle} className="w-full flex items-center justify-between text-left">
<div className="flex items-center gap-3">
<svg className={`w-8 h-8 ${isFallback ? "text-radio-muted" : "text-radio-accent"}`} fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
{isFallback ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
)}
</svg>
<div>
<div className="font-medium capitalize">{name}</div>
<div className="text-xs text-radio-muted">{isFallback ? "Emergency playlist" : path}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`text-2xl font-bold ${count > 0 ? (isFallback ? "text-radio-success" : "text-radio-text") : "text-radio-danger"}`}>
{count}
</span>
<svg className={`w-4 h-4 text-radio-muted transition-transform ${expanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{count === 0 && !expanded && (
<div className="text-xs text-radio-danger mt-2">
{isFallback
? "Add at least one audio file to prevent silence when all other sources fail"
: "No audio files — this schedule block will be silent"}
</div>
)}
{expanded && (
<div className="mt-3 border-t border-radio-border pt-3">
{loading ? (
<div className="text-sm text-radio-muted">Loading...</div>
) : files.length === 0 ? (
<div className="text-sm text-radio-muted">No files</div>
) : (
<ul className="space-y-1">
{files.map((file) => (
<li key={file.name} className="flex items-center gap-2 text-sm group">
<svg className="w-4 h-4 text-radio-muted flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H2.25A.75.75 0 011.5 15V9a.75.75 0 01.75-.75h4.5z" />
</svg>
<span className="truncate flex-1" title={file.name}>
{file.name}
</span>
<span className="text-xs text-radio-muted flex-shrink-0">
{formatSize(file.size)}
</span>
{moving === file.name ? (
<span className="text-xs text-radio-accent flex-shrink-0">Moving...</span>
) : (
<select
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-radio-bg border border-radio-border rounded px-1 py-0.5 text-radio-text flex-shrink-0"
value=""
onChange={(e) => {
if (e.target.value) handleMove(file.name, e.target.value);
}}
>
<option value="">Move to...</option>
{destinations.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
)}
</li>
))}
</ul>
)}
{error && (
<div className="text-xs text-radio-danger mt-2">{error}</div>
)}
</div>
)}
</div>
);
}
export default function MusicPanel() {
const [data, setData] = useState(null);
const load = () => api.getMusic().then(setData).catch(() => {});
useEffect(() => { load(); }, []);
if (!data) return <div className="text-radio-muted">Loading...</div>;
const allFolders = [
...data.directories.map((d) => d.folder),
"fallback",
];
return (
<div>
<h1 className="text-2xl font-bold mb-6">Music Library</h1>
<div className="grid md:grid-cols-2 gap-4">
{data.directories.map((dir) => (
<FolderCard
key={dir.folder}
folder={dir.folder}
name={dir.folder}
path={dir.path}
count={dir.file_count}
isFallback={false}
allFolders={allFolders}
onRefresh={load}
/>
))}
<FolderCard
folder="fallback"
name="Fallback"
path={data.fallback.path}
count={data.fallback.file_count}
isFallback={true}
allFolders={allFolders}
onRefresh={load}
/>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect } from "react";
import { api } from "../api";
export default function QueuePanel() {
const [queue, setQueue] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEnqueue, setShowEnqueue] = useState(false);
const [episodes, setEpisodes] = useState([]);
const refresh = () => {
setLoading(true);
api.getQueue()
.then(setQueue)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(refresh, []);
const handleDequeue = async (id) => {
try {
await api.dequeue(id);
refresh();
} catch (e) {
setError(e.message);
}
};
const openEnqueueModal = async () => {
try {
const data = await api.getEpisodes({ status: "downloaded", per_page: 50 });
setEpisodes(data.episodes.filter((ep) => !ep.queued));
setShowEnqueue(true);
} catch (e) {
setError(e.message);
}
};
const handleEnqueue = async (episodeId) => {
try {
await api.enqueue({ episode_id: episodeId });
setShowEnqueue(false);
refresh();
} catch (e) {
setError(e.message);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Podcast Queue</h1>
<button onClick={openEnqueueModal} className="btn btn-primary text-sm">
Enqueue Episode
</button>
</div>
{error && (
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{loading ? (
<div className="text-radio-muted">Loading...</div>
) : queue.length === 0 ? (
<div className="card text-center py-12">
<div className="text-radio-muted text-lg mb-2">Queue is empty</div>
<div className="text-sm text-radio-muted">Music is playing. Podcasts will appear here when new episodes are detected.</div>
</div>
) : (
<div className="space-y-2">
{queue.map((item) => (
<div key={item.id} className="card flex items-center gap-4">
<span className="text-radio-accent font-mono text-lg w-8 text-center">
{item.position}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{item.title}</div>
<div className="text-sm text-radio-muted">{item.feed_name}</div>
<div className="text-xs text-radio-muted">
Enqueued: {new Date(item.enqueued + "Z").toLocaleString()}
</div>
</div>
<button
onClick={() => handleDequeue(item.id)}
className="btn btn-danger text-sm"
>
Remove
</button>
</div>
))}
</div>
)}
{/* Enqueue Modal */}
{showEnqueue && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowEnqueue(false)}>
<div className="card w-full max-w-lg max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<h2 className="font-semibold text-lg mb-4">Enqueue Episode</h2>
{episodes.length === 0 ? (
<p className="text-radio-muted text-sm">No downloaded episodes available to enqueue.</p>
) : (
<ul className="space-y-2">
{episodes.map((ep) => (
<li key={ep.id} className="flex items-center gap-3 p-2 rounded hover:bg-radio-border/20">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{ep.title}</div>
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
</div>
<button onClick={() => handleEnqueue(ep.id)} className="btn btn-primary text-xs py-1 px-3">
Add
</button>
</li>
))}
</ul>
)}
<button onClick={() => setShowEnqueue(false)} className="btn btn-ghost mt-4 w-full text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { api } from "../api";
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS = [
{ num: 1, name: "Mon" },
{ num: 2, name: "Tue" },
{ num: 3, name: "Wed" },
{ num: 4, name: "Thu" },
{ num: 5, name: "Fri" },
{ num: 6, name: "Sat" },
{ num: 7, name: "Sun" },
];
const FOLDER_COLORS = {
morning: "bg-yellow-500/30 border-yellow-500/50",
day: "bg-orange-500/30 border-orange-500/50",
night: "bg-indigo-500/30 border-indigo-500/50",
weekend: "bg-emerald-500/30 border-emerald-500/50",
};
function getBlockForHour(blocks, dayNum, hour) {
const timeStr = `${String(hour).padStart(2, "0")}:00`;
for (const block of blocks) {
if (!block.days.includes(dayNum)) continue;
const start = block.start;
const end = block.end;
if (start <= end) {
if (timeStr >= start && timeStr < end) return block;
} else {
if (timeStr >= start || timeStr < end) return block;
}
}
return null;
}
export default function SchedulePanel() {
const [blocks, setBlocks] = useState([]);
useEffect(() => {
api.getSchedule().then(setBlocks).catch(() => {});
}, []);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Schedule</h1>
{/* Legend */}
<div className="flex gap-4 mb-4 flex-wrap">
{blocks.map((b) => (
<div key={b.name} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded border ${FOLDER_COLORS[b.folder] || "bg-gray-500/30 border-gray-500/50"}`} />
<span className="text-sm">
{b.name}
{b.active && <span className="badge badge-green ml-2">Active</span>}
</span>
</div>
))}
</div>
{/* Grid */}
<div className="card overflow-x-auto">
<div className="min-w-[700px]">
{/* Hour headers */}
<div className="flex">
<div className="w-12 flex-shrink-0" />
{HOURS.map((h) => (
<div key={h} className="flex-1 text-center text-xs text-radio-muted py-1">
{h}
</div>
))}
</div>
{/* Day rows */}
{DAYS.map((day) => (
<div key={day.num} className="flex items-center">
<div className="w-12 flex-shrink-0 text-xs text-radio-muted font-medium text-right pr-2 py-1">
{day.name}
</div>
{HOURS.map((h) => {
const block = getBlockForHour(blocks, day.num, h);
const colorClass = block
? FOLDER_COLORS[block.folder] || "bg-gray-500/30 border-gray-500/50"
: "bg-radio-border/20 border-transparent";
return (
<div
key={h}
className={`flex-1 h-8 border ${colorClass} ${block?.active ? "ring-1 ring-radio-accent" : ""}`}
title={block ? `${block.name} (${block.folder})` : "No schedule"}
/>
);
})}
</div>
))}
</div>
</div>
{/* Block details */}
<div className="grid md:grid-cols-2 gap-3 mt-6">
{blocks.map((b) => (
<div key={b.name} className={`card ${b.active ? "border-radio-accent/50" : ""}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{b.name}</span>
{b.active && <span className="badge badge-green">Active</span>}
</div>
<div className="text-sm text-radio-muted mt-1">
{b.day_names.join(", ")} &middot; {b.start} - {b.end}
</div>
<div className="text-xs text-radio-muted mt-1">
Folder: <code className="text-radio-accent">{b.folder}/</code>
</div>
</div>
))}
</div>
</div>
);
}