diff --git a/.gitignore b/.gitignore index 8038817..629f70c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ docs/superpowers/ # claude code per-project state .claude/ +# design handoff bundle from claude design — local reference only +.design-prototype/ + # generated configs and secrets config/*.env !config/*.env.example diff --git a/config/frontend.env.example b/config/frontend.env.example new file mode 100644 index 0000000..fb71aa0 --- /dev/null +++ b/config/frontend.env.example @@ -0,0 +1,5 @@ +NODE_ENV=production +HOST=0.0.0.0 +PORT=3000 +LIBRARY_ROOT=/library +DATA_ROOT=/now-playing diff --git a/config/liquidsoap.liq b/config/liquidsoap.liq index fab96fa..52e7d88 100644 --- a/config/liquidsoap.liq +++ b/config/liquidsoap.liq @@ -9,13 +9,24 @@ source_pw = environment.get(default="", "SOURCE_PW") def emit_now_playing(station, m) = filename = m["filename"] if filename != "" then + # decoder-reported metadata may be empty; fall back to request.duration which + # reads it directly from the file. -1.0 means unknown. + meta_dur = m["duration"] + dur_str = + if meta_dur != "" then + meta_dur + else + # request.duration returns float? in liquidsoap 2.3 — unwrap with default -1. + d = null.get(default=-1., request.duration(filename)) + if d > 0. then string(int_of_float(d)) else "" end + end payload = json() payload.add("station", station) payload.add("artist", m["artist"]) payload.add("title", m["title"]) payload.add("album", m["album"]) payload.add("filename", filename) - payload.add("duration", m["duration"]) + payload.add("duration", dur_str) payload.add("started_at", string(int_of_float(time()))) file.write(data=payload.stringify(), atomic=true, temp_dir="/now-playing", "/now-playing/#{station}.json") end diff --git a/docker-compose.yml b/docker-compose.yml index 1bbd3a7..55cf2b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,3 +45,26 @@ services: timeout: 5s retries: 3 start_period: 30s + + frontend: + build: ./frontend + image: denpa-radio-frontend:latest + restart: always + env_file: config/frontend.env + ports: + - '172.17.0.1:12001:3000' + volumes: + - type: bind + source: /mnt/trashbox/denpa-radio/library + target: /library + read_only: true + - type: bind + source: ./data/now-playing + target: /now-playing + read_only: true + healthcheck: + test: ['CMD', 'wget', '-q', '-O', '-', 'http://127.0.0.1:3000/api/stations.json'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..de131f2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ +USER node +EXPOSE 3000 +CMD ["node", "./dist/server/entry.mjs"] diff --git a/frontend/src/components/FooterStrip.astro b/frontend/src/components/FooterStrip.astro new file mode 100644 index 0000000..dc851fe --- /dev/null +++ b/frontend/src/components/FooterStrip.astro @@ -0,0 +1,8 @@ +--- +--- + diff --git a/frontend/src/components/HeroPlayer.tsx b/frontend/src/components/HeroPlayer.tsx new file mode 100644 index 0000000..3fc68cb --- /dev/null +++ b/frontend/src/components/HeroPlayer.tsx @@ -0,0 +1,335 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { Station, NowPlaying, IcecastStatusJson } from '@lib/types'; +import { Tape } from './Tape'; +import { Spectrum } from './Spectrum'; +import { playerStore } from '@lib/store'; +import { fmtMs, fmtJpDate, fmtJpTime } from '@lib/format'; + +const PUBLIC_ORIGIN = 'https://denpa.femboy.page'; +const POLL_NOW_MS = 5_000; +const POLL_LISTENERS_MS = 30_000; + +interface Props { + initialStations: Station[]; +} + +type Format = 'mp3' | 'opus'; + +const lsGet = (k: string): string | null => { + try { return localStorage.getItem(k); } catch { return null; } +}; +const lsSet = (k: string, v: string) => { + try { localStorage.setItem(k, v); } catch { /* ignore */ } +}; + +export function HeroPlayer({ initialStations }: Props) { + // ssr-safe defaults; localStorage + Date reads happen post-mount to avoid + // hydration mismatches (#418, #423, #425). + const [stations, setStations] = useState(initialStations); + const [selectedId, setSelectedId] = useState(initialStations[0]?.id ?? ''); + const [format, setFormat] = useState('mp3'); + const [vol, setVol] = useState(0.72); + const [muted, setMuted] = useState(false); + const [playing, setPlaying] = useState(false); + const [now, setNow] = useState(null); + const [listeners, setListeners] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [copyState, setCopyState] = useState<'idle' | 'ok'>('idle'); + const [clockText, setClockText] = useState<{ date: string; time: string }>({ date: '', time: '' }); + + // restore persisted UI state once on the client + useEffect(() => { + const savedStation = lsGet('denpa:station'); + if (savedStation && initialStations.some((s) => s.id === savedStation)) { + setSelectedId(savedStation); + } + if (lsGet('denpa:format') === 'opus') setFormat('opus'); + const savedVol = parseFloat(lsGet('denpa:vol') ?? ''); + if (!isNaN(savedVol)) setVol(Math.max(0, Math.min(1, savedVol))); + }, [initialStations]); + + const audioRef = useRef(null); + const station = stations.find((s) => s.id === selectedId); + const streamUrl = station ? `${PUBLIC_ORIGIN}${station.mounts[format]}` : ''; + + // refresh stations periodically (cheap; backend caches) + useEffect(() => { + let aborted = false; + const refresh = async () => { + try { + const r = await fetch('/api/stations.json'); + if (!r.ok) return; + const ss = await r.json() as Station[]; + if (!aborted && ss.length) { + setStations(ss); + if (!ss.some((s) => s.id === selectedId)) { + const first = ss[0]; + if (first) setSelectedId(first.id); + } + } + } catch { /* ignore */ } + }; + const id = setInterval(refresh, 60_000); + return () => { aborted = true; clearInterval(id); }; + }, [selectedId]); + + // share audio element with Spectrum + useEffect(() => { + playerStore.setAudio(audioRef.current); + return () => playerStore.setAudio(null); + }, []); + + // wire