commit
1af570f5f9
4 changed files with 1955 additions and 0 deletions
@ -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 |
||||
``` |
||||
|
||||
@ -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) |
||||
|
||||
@ -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> |
||||
|
||||
@ -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…
Reference in new issue