Browse Source

Initial commit. Pretty functional, but needs polish, especially the UX.

master
commit
1af570f5f9
  1. 28
      README.md
  2. 775
      music-api.py
  3. 527
      radio.html
  4. 625
      simple.css

28
README.md

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
# HushPupPi Pirate Radio
Simple pirate radio interface (yes, actual FM radio) for Raspberry Pi with DJ-style queueing, station ID, RDS transmission, and a decent web interface.
This is some code which was mostly hallucinated by Copilot. It did great with a lot of guidance, but don't expect miracles, and especially don't expect it to have great performance or security.
## Usage
1. Put radio.html and simple.css into your webroot—I used lighttpd.
2. Put music-api.py somewhere root can run it. I suggest `/usr/local/bin/`
3. The python script, music-api.py, will host a Flask daemon on port 5001. Set up your webserver to proxy requests to /api/ to localhost:5001.
The python script calls [PiFmAdv](https://github.com/miegl/PiFmAdv), which you'll need to compile yourself.
You should be able to figure out Python requirements from the `import` statements at the top of the file; they are pretty modest. Likewise, there are some constants towards the top which point to the location of music files, a cache location for album art, and for a SQLite database of indexed music.
## Autostart
You can create a simple systemd service with the following:
```
[Unit]
Description=Music API for FM transmitter
After=network.target
[Service]
ExecStart=/usr/local/bin/music-api.py
Restart=always
[Install]
WantedBy=multi-user.target
```

775
music-api.py

@ -0,0 +1,775 @@ @@ -0,0 +1,775 @@
#!/usr/bin/env python3
import os
import time
from datetime import datetime, timezone
import random
import threading
import subprocess
import hashlib
import sqlite3
from flask import Flask, jsonify, request, send_from_directory
# -----------------------------
# Configuration
# -----------------------------
MUSIC_DIR = "/media/usb0/music"
STATION_ID_DIR = "/usr/local/share/music_api/station_ids"
CACHE_DIR = os.path.join(MUSIC_DIR, ".cache")
ALBUM_ART_CACHE_DIR = os.path.join(CACHE_DIR, "albumart")
FREQ = "103.1"
STATION_NAME = "HushPup"
RDS_FIFO = "/tmp/pifm_rds"
STATION_ID_INTERVAL = 600 # 10 minutes
STATION_ID_SONG_THRESHOLD = 3
# Audio format for decoder/wrapper
SAMPLE_RATE = 44100
CHANNELS = 2
SAMPLE_WIDTH = 2 # bytes per sample (16-bit)
SILENCE_MS_AFTER_TRACK = 50
TRACKS = {} # key: relative path -> value: {"id","filename","path","duration","album_art"}
DB_PATH = os.path.join(CACHE_DIR, "track_cache.sqlite3")
_db_lock = threading.Lock()
# -----------------------------
# State
# -----------------------------
queue = []
current_track = None
stop_flag = False
skip_flag = False
shuffle_mode = False
repeat_mode = "off" # off, repeat_one, repeat_all
pifm_proc = None
rds_pipe = None
wav_wrap = None
last_station_id_time = time.time()
songs_since_last_id = 0
queue_lock = threading.Lock()
app = Flask(__name__)
# -----------------------------
# Helpers
# -----------------------------
def init_db():
os.makedirs(ALBUM_ART_CACHE_DIR, exist_ok=True)
with _db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30, isolation_level=None)
try:
# WAL for concurrency and faster writes
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
conn.execute("""
CREATE TABLE IF NOT EXISTS tracks (
path TEXT PRIMARY KEY,
mtime INTEGER,
size INTEGER,
duration REAL,
album_art TEXT,
sha1 TEXT,
updated_at TEXT
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_mtime ON tracks(mtime);")
finally:
conn.close()
def db_get_entry(path):
with _db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
cur = conn.execute("SELECT mtime,size,duration,album_art,sha1 FROM tracks WHERE path = ?",
(path,))
row = cur.fetchone()
if not row:
return None
return {"mtime": row[0], "size": row[1], "duration": row[2],
"album_art": row[3], "sha1": row[4]}
finally:
conn.close()
def db_upsert_entry(path, mtime, size, duration, album_art, sha1=None):
now = datetime.now(timezone.utc).isoformat()
with _db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
conn.execute("BEGIN")
conn.execute("""
INSERT INTO tracks(path,mtime,size,duration,album_art,sha1,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(path) DO UPDATE SET
mtime=excluded.mtime,
size=excluded.size,
duration=excluded.duration,
album_art=excluded.album_art,
sha1=excluded.sha1,
updated_at=excluded.updated_at
""", (path, mtime, size, duration, album_art, sha1, now))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
def db_delete_paths(paths):
"""Delete multiple paths (iterable) in a single transaction."""
with _db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
conn.execute("BEGIN")
conn.executemany("DELETE FROM tracks WHERE path = ?", ((p,) for p in paths))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
def prune_stale_db_entries(valid_paths_set):
"""
Remove DB rows whose path is not in valid_paths_set.
This keeps the DB clean when files are removed or renamed.
"""
# Collect all DB paths and compute difference
with _db_lock:
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
cur = conn.execute("SELECT path FROM tracks")
db_paths = {row[0] for row in cur.fetchall()}
finally:
conn.close()
stale = db_paths - set(valid_paths_set)
if not stale:
return
# Delete stale rows in batches
batch = []
for p in stale:
batch.append(p)
if len(batch) >= 200:
db_delete_paths(batch)
batch = []
if batch:
db_delete_paths(batch)
app.logger.info("prune_stale_db_entries: removed %d stale entries", len(stale))
def open_fifo_nonblocking(path):
fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
return os.fdopen(fd, "w", buffering=1)
def pick_station_id_file():
if not os.path.isdir(STATION_ID_DIR):
return None
files = [f for f in os.listdir(STATION_ID_DIR) if os.path.isfile(os.path.join(STATION_ID_DIR, f))]
if not files:
return None
return os.path.join(STATION_ID_DIR, random.choice(files))
def set_rds(text: str):
global rds_pipe
try:
if rds_pipe:
rds_pipe.write(text[:64] + "\n")
rds_pipe.flush()
except Exception:
pass
def _safe_hash_name(rel_path: str) -> str:
return hashlib.sha1(rel_path.encode("utf-8")).hexdigest() + ".jpg"
# -----------------------------
# Persistent WAV wrapper + pifmadv
# -----------------------------
def start_wav_wrapper():
"""
Start a persistent ffmpeg process that reads raw PCM from stdin and
writes a WAV stream to stdout. The wrapper stdin remains open across tracks.
"""
global wav_wrap
if wav_wrap and wav_wrap.poll() is None:
return
wrapper_args = [
"ffmpeg",
"-f", "s16le", # raw PCM input
"-ac", str(CHANNELS),
"-ar", str(SAMPLE_RATE),
"-i", "-", # stdin
"-f", "wav", # output WAV stream
"-ac", str(CHANNELS),
"-ar", str(SAMPLE_RATE),
"-" # stdout → pifmadv
]
wav_wrap = subprocess.Popen(
wrapper_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0
)
def start_pifm():
"""
Start the persistent wrapper and pifmadv. pifmadv reads from wrapper stdout.
"""
global pifm_proc, rds_pipe, wav_wrap
if not os.path.exists(RDS_FIFO):
try:
os.mkfifo(RDS_FIFO)
except FileExistsError:
pass
start_wav_wrapper()
# Replace these args with your actual pifmadv invocation
pifm_args = [
"pifmadv",
"--freq", FREQ,
"--pi", "beef",
"--pty", "9",
"--ps", STATION_NAME,
"--ctl", RDS_FIFO,
"--power", "7",
"--preemph", "us",
"--cutoff", "20500",
"--audio", "-",
]
# Start pifmadv reading from wrapper stdout
pifm_proc = subprocess.Popen(
pifm_args,
stdin=wav_wrap.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Close wrapper stdout in parent so pifm owns the read end
try:
wav_wrap.stdout.close()
except Exception:
pass
time.sleep(0.1)
rds_pipe = open_fifo_nonblocking(RDS_FIFO)
def write_silence(seconds: float):
"""Feed raw PCM silence into the WAV wrapper (non-blocking)."""
global wav_wrap
if not wav_wrap or wav_wrap.poll() is not None:
return
frames = int(SAMPLE_RATE * seconds)
silence = (b"\x00" * SAMPLE_WIDTH * CHANNELS) * frames
try:
# Acquire a short lock to avoid concurrent writes from multiple threads
with queue_lock:
wav_wrap.stdin.write(silence)
wav_wrap.stdin.flush()
except BrokenPipeError:
pass
except Exception:
pass
def _safe_art_name(rel_path: str) -> str:
"""Stable filesystem-safe name for album art based on relative path."""
h = hashlib.sha1(rel_path.encode("utf-8")).hexdigest()
return f"{h}.jpg"
def probe_duration(path: str, timeout: int = 6):
"""Run ffprobe once to get duration in seconds or None on failure."""
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=timeout
)
out = result.stdout.strip()
if not out:
return None
return float(out)
except Exception:
return None
def extract_album_art(path: str, rel_path: str, timeout: int = 10):
"""
Extract embedded album art to ALBUM_ART_CACHE_DIR and return cache filename,
or None if no art or extraction failed.
"""
os.makedirs(ALBUM_ART_CACHE_DIR, exist_ok=True)
art_name = _safe_art_name(rel_path)
art_path = os.path.join(ALBUM_ART_CACHE_DIR, art_name)
# If already cached, return immediately
if os.path.exists(art_path) and os.path.getsize(art_path) > 0:
return art_name
# Try to extract the first video stream / attached pic
# Use ffmpeg to write a jpeg; if none exists, ffmpeg will exit nonzero
try:
# -y overwrite, -v error quiets output
subprocess.run(
[
"ffmpeg", "-y", "-v", "error",
"-i", path,
"-an", "-vcodec", "copy",
art_path
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout,
check=False
)
# If file exists and non-empty, return it
if os.path.exists(art_path) and os.path.getsize(art_path) > 0:
return art_name
except Exception:
pass
# Cleanup any zero-length file
try:
if os.path.exists(art_path) and os.path.getsize(art_path) == 0:
os.remove(art_path)
except Exception:
pass
return None
# -----------------------------
# Pump helper for decoder -> wrapper
# -----------------------------
def _pump_decoder_to_wrapper(decoder_stdout, stop_event):
"""
Copy bytes from decoder_stdout to wrapper_proc.stdin until EOF or stop_event.
Do NOT close wrapper_proc.stdin when done.
"""
global wav_wrap
try:
bufsize = 64 * 1024
while not stop_event.is_set():
chunk = decoder_stdout.read(bufsize)
if not chunk:
break
try:
with queue_lock:
if wav_wrap and wav_wrap.poll() is None:
wav_wrap.stdin.write(chunk)
wav_wrap.stdin.flush()
else:
# wrapper died; stop pumping
break
except BrokenPipeError:
break
except Exception:
break
except Exception:
pass
finally:
try:
decoder_stdout.close()
except Exception:
pass
def write_silence_ms(ms: int = SILENCE_MS_AFTER_TRACK):
write_silence(ms / 1000.0)
# -----------------------------
# Robust stream_file implementation
# -----------------------------
def stream_file(path: str, allow_skip=True) -> bool:
"""
Decode a file to RAW PCM (s16le, stereo, SAMPLE_RATE)
and pump into the persistent WAV wrapper stdin.
Returns True if decoder exited with code 0 (normal EOF).
Returns False if aborted (skip/stop) or decoder error.
"""
global wav_wrap, stop_flag, skip_flag
if not wav_wrap or wav_wrap.poll() is not None:
print("wav_wrap is not running")
return False
# Build decoder args to emit raw PCM matching wrapper input
decoder_args = [
"ffmpeg", "-nostdin", "-v", "error",
"-i", path,
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS),
"pipe:1"
]
try:
decoder = subprocess.Popen(
decoder_args,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
bufsize=0
)
except Exception as e:
print("[stream_file] failed to start decoder:", e)
return False
stop_event = threading.Event()
pump_thread = threading.Thread(target=_pump_decoder_to_wrapper, args=(decoder.stdout, stop_event), daemon=True)
pump_thread.start()
try:
while True:
ret = decoder.poll()
if ret is not None:
# Decoder exited; let pump flush briefly
pump_thread.join(timeout=1)
write_silence_ms(50)
return ret == 0
# Abort on global stop
if stop_flag:
try:
decoder.terminate()
except Exception:
pass
stop_event.set()
pump_thread.join(timeout=1)
write_silence_ms()
return False
# Abort on skip
if allow_skip and skip_flag:
try:
decoder.terminate()
except Exception:
pass
stop_event.set()
pump_thread.join(timeout=1)
# Do not clear skip_flag here; worker will clear under lock
write_silence_ms()
return False
time.sleep(0.05)
except Exception as e:
print("[stream_file] Exception:", e)
try:
decoder.kill()
except Exception:
pass
stop_event.set()
pump_thread.join(timeout=1)
write_silence_ms()
return False
# -----------------------------
# Discover tracks (at startup)
# -----------------------------
def discover_tracks(use_hash=False, save_every_n=200):
"""
Walk MUSIC_DIR, reuse DB rows when mtime+size match.
Probe/extract only for new/changed files. Save progress periodically.
"""
init_db()
TRACKS.clear()
seen = set()
count = 0
for root, dirs, files in os.walk(MUSIC_DIR):
for name in files:
if not name.lower().endswith((".mp3", ".flac", ".wav", ".m4a", ".ogg", ".aac")):
continue
full = os.path.join(root, name)
rel = os.path.relpath(full, MUSIC_DIR)
seen.add(rel)
# stat
try:
st = os.stat(full)
mtime = int(st.st_mtime)
size = st.st_size
except Exception:
mtime, size = None, None
cached = db_get_entry(rel)
reuse = False
duration = None
art = None
sha = None
if cached and cached.get("mtime") == mtime and cached.get("size") == size:
duration = cached.get("duration")
art = cached.get("album_art")
sha = cached.get("sha1")
reuse = True
elif cached and use_hash:
# compute sha1 only when mtime/size differ and use_hash requested
sha_now = compute_sha1(full)
if sha_now and sha_now == cached.get("sha1"):
duration = cached.get("duration")
art = cached.get("album_art")
# update mtime/size to current values
db_upsert_entry(rel, mtime, size, duration, art, sha_now)
reuse = True
if not reuse:
duration = probe_duration(full)
art = extract_album_art(full, rel)
sha = compute_sha1(full) if use_hash else None
db_upsert_entry(rel, mtime, size, duration, art, sha)
TRACKS[rel] = {
"id": rel,
"filename": name,
"path": rel,
"duration": duration,
"album_art": art
}
count += 1
if save_every_n and count % save_every_n == 0:
# small progress log
app.logger.info("discover_tracks: processed %d files", count)
# prune stale DB rows (entries not seen in this scan)
prune_stale_db_entries(seen)
app.logger.info("discover_tracks: finished, total=%d", len(TRACKS))
# -----------------------------
# Playback Worker
# -----------------------------
def worker():
global current_track, stop_flag, skip_flag, last_station_id_time, songs_since_last_id
global queue, shuffle_mode, repeat_mode
while True:
try:
if stop_flag:
with queue_lock:
current_track = None
queue.clear()
write_silence(0.25)
time.sleep(0.5)
continue
# Station ID condition: whichever comes first (time or songs)
now = time.time()
with queue_lock:
time_elapsed = now - last_station_id_time
songs_count = songs_since_last_id
if time_elapsed >= STATION_ID_INTERVAL or songs_count >= STATION_ID_SONG_THRESHOLD:
id_file = pick_station_id_file()
if id_file:
with queue_lock:
current_track = None
songs_since_last_id = 0
set_rds("RT HushPupPi Station ID")
stream_file(id_file, allow_skip=False)
write_silence(0.25)
# Reset the last_station_id_time after playing
last_station_id_time = time.time()
else:
# No station id files; reset timer so we don't spin
last_station_id_time = time.time()
with queue_lock:
if not queue:
current_track = None
empty = True
else:
empty = False
if empty:
write_silence(0.25)
time.sleep(0.5)
continue
# Shuffle only when building the play order
if shuffle_mode:
with queue_lock:
random.shuffle(queue)
# Repeat_one: loop the first track
if repeat_mode == "repeat_one":
with queue_lock:
current_track = queue[0]
path_current = os.path.join(MUSIC_DIR, current_track)
set_rds(f"RT {current_track}")
played_ok = stream_file(path_current)
write_silence(0.25)
with queue_lock:
current_track = None
time.sleep(0.1)
continue
# Normal / repeat_all
with queue_lock:
current_track = queue[0]
path_current = os.path.join(MUSIC_DIR, current_track)
set_rds(f"RT {current_track}")
print(f"[WORKER] Starting: {current_track} | queue={queue} | skip={skip_flag} stop={stop_flag}")
played_ok = stream_file(path_current)
# Snapshot flags under lock to avoid races
with queue_lock:
local_skip = skip_flag
local_stop = stop_flag
head = queue[0] if queue else None
print(f"[WORKER] Finished stream_file: {current_track} played_ok={played_ok} skip={local_skip} stop={local_stop}")
if local_stop:
# stop_flag handled at top of loop
continue
if played_ok:
# normal completion: remove head only if it matches
with queue_lock:
if queue and queue[0] == head:
popped = queue.pop(0)
print(f"[WORKER] popped after play: {popped} | queue now {queue}")
if repeat_mode == "repeat_all":
queue.append(popped)
else:
# not played_ok: if it was a skip, clear skip and pop once
if local_skip:
with queue_lock:
skip_flag = False
if queue and queue[0] == head:
popped = queue.pop(0)
print(f"[WORKER] Skip: popped {popped} | queue now {queue}")
write_silence(0.25)
continue
# Real failure: log and drop the track to avoid infinite loop
print("[WORKER] Track failed to play:", current_track)
with queue_lock:
if queue and queue[0] == head:
dropped = queue.pop(0)
print(f"[WORKER] Dropped failed track: {dropped}")
write_silence(0.25)
time.sleep(0.5)
continue
with queue_lock:
current_track = None
time.sleep(0.1)
except Exception as e:
print("[WORKER] Exception in worker loop:", e)
time.sleep(1)
# -----------------------------
# API Endpoints
# -----------------------------
@app.get("/tracks")
def list_tracks():
# Return precomputed metadata as a sorted list
items = list(TRACKS.values())
items.sort(key=lambda x: x["path"])
return jsonify(items)
@app.get("/album_art/<name>")
def album_art(name):
# Serve cached album art files
safe = os.path.join(ALBUM_ART_CACHE_DIR, name)
if not os.path.exists(safe):
return ("", 404)
return send_from_directory(ALBUM_ART_CACHE_DIR, name)
@app.get("/queue")
def get_queue():
with queue_lock:
return jsonify(list(queue))
@app.post("/queue")
def add_to_queue():
global queue, stop_flag
data = request.get_json(force=True)
track = data.get("track")
if not track:
return jsonify({"error": "no track"}), 400
# Validate against discovered tracks
if track not in TRACKS:
return jsonify({"error": "unknown track"}), 404
with queue_lock:
queue.append(track)
stop_flag = False
return jsonify({"queued": track})
@app.get("/now")
def now_playing():
with queue_lock:
art = None
if current_track and current_track in TRACKS:
art_name = TRACKS[current_track].get("album_art")
if art_name:
art = f"/album_art/{art_name}"
return jsonify({"track": current_track, "album_art": art})
@app.post("/shuffle")
def set_shuffle():
global shuffle_mode
data = request.get_json(force=True)
shuffle_mode = bool(data.get("enabled", False))
return jsonify({"shuffle": shuffle_mode})
@app.post("/repeat")
def set_repeat():
global repeat_mode
data = request.get_json(force=True)
mode = data.get("mode", "off")
if mode in ["off", "repeat_one", "repeat_all"]:
repeat_mode = mode
return jsonify({"repeat": repeat_mode})
@app.post("/skip")
def skip():
global skip_flag
with queue_lock:
skip_flag = True
return jsonify({"status": "skipped"})
@app.post("/stop")
def stop():
global stop_flag, queue, current_track
with queue_lock:
stop_flag = True
queue.clear()
current_track = None
return jsonify({"status": "stopping"})
# -----------------------------
# Startup
# -----------------------------
if __name__ == "__main__":
os.makedirs(MUSIC_DIR, exist_ok=True)
os.makedirs(STATION_ID_DIR, exist_ok=True)
discover_tracks()
start_pifm()
threading.Thread(target=worker, daemon=True).start()
# Reduce Flask logging noise in production
import logging
logging.getLogger("werkzeug").setLevel(logging.ERROR)
app.run(host="0.0.0.0", port=5001, debug=False)

527
radio.html

@ -0,0 +1,527 @@ @@ -0,0 +1,527 @@
<!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>

625
simple.css

@ -0,0 +1,625 @@ @@ -0,0 +1,625 @@
/* Global variables. */
:root {
/* Set sans-serif & mono fonts */
--sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir,
"Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica,
"Helvetica Neue", sans-serif;
--mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
/* Default (light) theme */
--bg: #fff;
--accent-bg: #f5f7ff;
--text: #212121;
--text-light: #585858;
--border: #898EA4;
--accent: #0d47a1;
--code: #d81b60;
--preformatted: #444;
--marked: #ffdd33;
--disabled: #efefef;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #212121;
--accent-bg: #2b2b2b;
--text: #dcdcdc;
--text-light: #ababab;
--accent: #ffb300;
--code: #f06292;
--preformatted: #ccc;
--disabled: #111;
}
/* Add a bit of transparency so light media isn't so glaring in dark mode */
img,
video {
opacity: 0.8;
}
}
/* Reset box-sizing */
*, *::before, *::after {
box-sizing: border-box;
}
/* Reset default appearance */
textarea,
select,
input,
progress {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
html {
/* Set the font globally */
font-family: var(--sans-font);
scroll-behavior: smooth;
}
/* Make the body a nice central block */
body {
color: var(--text);
background-color: var(--bg);
font-size: 1.15rem;
line-height: 1.5;
display: grid;
grid-template-columns: 1fr min(45rem, 90%) 1fr;
margin: 0;
}
body > * {
grid-column: 2;
}
/* Make the header bg full width, but the content inline with body */
body > header {
background-color: var(--accent-bg);
border-bottom: 1px solid var(--border);
text-align: center;
padding: 0 0.5rem 2rem 0.5rem;
grid-column: 1 / -1;
}
body > header h1 {
max-width: 1200px;
margin: 1rem auto;
}
body > header p {
max-width: 40rem;
margin: 1rem auto;
}
/* Add a little padding to ensure spacing is correct between content and header > nav */
main {
padding-top: 1.5rem;
}
body > footer {
margin-top: 4rem;
padding: 2rem 1rem 1.5rem 1rem;
color: var(--text-light);
font-size: 0.9rem;
text-align: center;
border-top: 1px solid var(--border);
}
/* Format headers */
h1 {
font-size: 3rem;
}
h2 {
font-size: 2.6rem;
margin-top: 3rem;
}
h3 {
font-size: 2rem;
margin-top: 3rem;
}
h4 {
font-size: 1.44rem;
}
h5 {
font-size: 1.15rem;
}
h6 {
font-size: 0.96rem;
}
/* Prevent long strings from overflowing container */
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
/* Fix line height when title wraps */
h1,
h2,
h3 {
line-height: 1.1;
}
/* Reduce header size on mobile */
@media only screen and (max-width: 720px) {
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2.1rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.25rem;
}
}
/* Format links & buttons */
a,
a:visited {
color: var(--accent);
}
a:hover {
text-decoration: none;
}
button,
[role="button"],
input[type="submit"],
input[type="reset"],
input[type="button"],
label[type="button"] {
border: none;
border-radius: 5px;
background-color: var(--accent);
font-size: 1rem;
color: var(--bg);
padding: 0.7rem 0.9rem;
margin: 0.5rem 0;
}
button[disabled],
[role="button"][aria-disabled="true"],
input[type="submit"][disabled],
input[type="reset"][disabled],
input[type="button"][disabled],
input[type="checkbox"][disabled],
input[type="radio"][disabled],
select[disabled] {
cursor: not-allowed;
}
input:disabled,
textarea:disabled,
select:disabled,
button[disabled] {
cursor: not-allowed;
background-color: var(--disabled);
color: var(--text-light)
}
input[type="range"] {
padding: 0;
}
/* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */
abbr[title] {
cursor: help;
text-decoration-line: underline;
text-decoration-style: dotted;
}
button:enabled:hover,
[role="button"]:not([aria-disabled="true"]):hover,
input[type="submit"]:enabled:hover,
input[type="reset"]:enabled:hover,
input[type="button"]:enabled:hover,
label[type="button"]:hover {
filter: brightness(1.4);
cursor: pointer;
}
button:focus-visible:where(:enabled, [role="button"]:not([aria-disabled="true"])),
input:enabled:focus-visible:where(
[type="submit"],
[type="reset"],
[type="button"]
) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Format navigation */
header > nav {
font-size: 1rem;
line-height: 2;
padding: 1rem 0 0 0;
}
/* Use flexbox to allow items to wrap, as needed */
header > nav ul,
header > nav ol {
align-content: space-around;
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
list-style-type: none;
margin: 0;
padding: 0;
}
/* List items are inline elements, make them behave more like blocks */
header > nav ul li,
header > nav ol li {
display: inline-block;
}
header > nav a,
header > nav a:visited {
margin: 0 0.5rem 1rem 0.5rem;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
display: inline-block;
padding: 0.1rem 1rem;
text-decoration: none;
}
header > nav a:hover {
border-color: var(--accent);
color: var(--accent);
cursor: pointer;
}
/* Reduce nav side on mobile */
@media only screen and (max-width: 720px) {
header > nav a {
border: none;
padding: 0;
text-decoration: underline;
line-height: 1;
}
}
/* Consolidate box styling */
aside, details, pre, progress {
background-color: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
margin-bottom: 1rem;
}
aside {
font-size: 1rem;
width: 30%;
padding: 0 15px;
margin-left: 15px;
float: right;
}
/* Make aside full-width on mobile */
@media only screen and (max-width: 720px) {
aside {
width: 100%;
float: none;
margin-left: 0;
}
}
article, fieldset {
border: 1px solid var(--border);
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
}
article h2:first-child,
section h2:first-child {
margin-top: 1rem;
}
section {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 2rem 1rem;
margin: 3rem 0;
}
/* Don't double separators when chaining sections */
section + section,
section:first-child {
border-top: 0;
padding-top: 0;
}
section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
details {
padding: 0.7rem 1rem;
}
summary {
cursor: pointer;
font-weight: bold;
padding: 0.7rem 1rem;
margin: -0.7rem -1rem;
word-break: break-all;
}
details[open] > summary + * {
margin-top: 0;
}
details[open] > summary {
margin-bottom: 0.5rem;
}
details[open] > :last-child {
margin-bottom: 0;
}
/* Format tables */
table {
border-collapse: collapse;
display: block;
margin: 1.5rem 0;
overflow: auto;
width: 100%;
}
td,
th {
border: 1px solid var(--border);
text-align: left;
padding: 0.5rem;
}
th {
background-color: var(--accent-bg);
font-weight: bold;
}
tr:nth-child(even) {
/* Set every other cell slightly darker. Improves readability. */
background-color: var(--accent-bg);
}
table caption {
font-weight: bold;
margin-bottom: 0.5rem;
}
/* Format forms */
textarea,
select,
input {
font-size: inherit;
font-family: inherit;
padding: 0.5rem;
margin-bottom: 0.5rem;
color: var(--text);
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
box-shadow: none;
max-width: 100%;
display: inline-block;
}
label {
display: block;
}
textarea:not([cols]) {
width: 100%;
}
/* Add arrow to drop-down */
select:not([multiple]) {
background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
linear-gradient(135deg, var(--text) 51%, transparent 49%);
background-position: calc(100% - 15px), calc(100% - 10px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 25px;
}
/* checkbox and radio button style */
input[type="checkbox"],
input[type="radio"] {
vertical-align: middle;
position: relative;
width: min-content;
}
input[type="checkbox"] + label,
input[type="radio"] + label {
display: inline-block;
}
input[type="radio"] {
border-radius: 100%;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background-color: var(--accent);
}
input[type="checkbox"]:checked::after {
/* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
content: " ";
width: 0.18em;
height: 0.32em;
border-radius: 0;
position: absolute;
top: 0.05em;
left: 0.17em;
background-color: transparent;
border-right: solid var(--bg) 0.08em;
border-bottom: solid var(--bg) 0.08em;
font-size: 1.8em;
transform: rotate(45deg);
}
input[type="radio"]:checked::after {
/* creates a colored circle for the checked radio button */
content: " ";
width: 0.25em;
height: 0.25em;
border-radius: 100%;
position: absolute;
top: 0.125em;
background-color: var(--bg);
left: 0.125em;
font-size: 32px;
}
/* Makes input fields wider on smaller screens */
@media only screen and (max-width: 720px) {
textarea,
select,
input {
width: 100%;
}
}
/* Set a height for color input */
input[type="color"] {
height: 2.5rem;
padding: 0.2rem;
}
/* do not show border around file selector button */
input[type="file"] {
border: 0;
}
/* Misc body elements */
hr {
border: none;
height: 1px;
background: var(--border);
margin: 1rem auto;
}
mark {
padding: 2px 5px;
border-radius: 4px;
background-color: var(--marked);
}
img,
video {
max-width: 100%;
height: auto;
border-radius: 5px;
}
figure {
margin: 0;
text-align: center;
}
figcaption {
font-size: 0.9rem;
color: var(--text-light);
margin-bottom: 1rem;
}
blockquote {
margin: 2rem 0 2rem 2rem;
padding: 0.4rem 0.8rem;
border-left: 0.35rem solid var(--accent);
color: var(--text-light);
font-style: italic;
}
cite {
font-size: 0.9rem;
color: var(--text-light);
font-style: normal;
}
dt {
color: var(--text-light);
}
/* Use mono font for code elements */
code,
pre,
pre span,
kbd,
samp {
font-family: var(--mono-font);
color: var(--code);
}
kbd {
color: var(--preformatted);
border: 1px solid var(--preformatted);
border-bottom: 3px solid var(--preformatted);
border-radius: 5px;
padding: 0.1rem 0.4rem;
}
pre {
padding: 1rem 1.4rem;
max-width: 100%;
overflow: auto;
color: var(--preformatted);
}
/* Fix embedded code within pre */
pre code {
color: var(--preformatted);
background: none;
margin: 0;
padding: 0;
}
/* Progress bars */
/* Declarations are repeated because you */
/* cannot combine vendor-specific selectors */
progress {
width: 100%;
}
progress:indeterminate {
background-color: var(--accent-bg);
}
progress::-webkit-progress-bar {
border-radius: 5px;
background-color: var(--accent-bg);
}
progress::-webkit-progress-value {
border-radius: 5px;
background-color: var(--accent);
}
progress::-moz-progress-bar {
border-radius: 5px;
background-color: var(--accent);
transition-property: width;
transition-duration: 0.3s;
}
progress:indeterminate::-moz-progress-bar {
background-color: var(--accent-bg);
}
Loading…
Cancel
Save