Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67beae5f6f | |||
| bdecc8c819 | |||
| a544649a22 | |||
| 2cd7cd3eab | |||
| a83d063814 | |||
| 1c4c875c10 | |||
| 9eaa16d818 | |||
| 3b4ce1ce39 | |||
| ed76fbd379 | |||
| 0ff5ecd9d0 | |||
| 7cdc8f0d44 | |||
| baef915561 | |||
| e0d93b03ae | |||
| 870a41a2f7 | |||
| 6bcf18c1bf | |||
| 40ac86717a | |||
| 8bca1767bd | |||
| 48955634af | |||
| 23d98320df | |||
| cc1f278fb8 | |||
| 08f3f6e7da | |||
| 83df3b4602 | |||
| 68a02ff250 | |||
| fcb783ffca | |||
| f6ce00a6b1 | |||
| 703d7532f7 | |||
| 824192b856 | |||
| d3e919d4c8 | |||
| 52c6750a51 | |||
| abca97b4ac | |||
| 922f3d9e10 | |||
| 21d214abe9 | |||
| 9500a93fde | |||
| 38394f40a6 | |||
| 8d00c10b9f | |||
| 9f9c2148b4 | |||
| eb701bbb8a | |||
| 04835f31b6 | |||
| 01c281ca80 | |||
| 65d88be032 | |||
| 7ab3c850bf | |||
| f069eef8a8 | |||
| d16bc80ac8 | |||
| a7256bc13c | |||
| a1e24c6a81 | |||
| 3b8400b2ed | |||
| f1a0d1ddef | |||
| 6bec95a563 |
83 changed files with 18362 additions and 10 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -4,6 +4,10 @@ docs/superpowers/
|
||||||
# claude code per-project state
|
# claude code per-project state
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# design handoff bundle from claude design — local reference only
|
||||||
|
.design-prototype/
|
||||||
|
.design-prototype-stream/
|
||||||
|
|
||||||
# generated configs and secrets
|
# generated configs and secrets
|
||||||
config/*.env
|
config/*.env
|
||||||
!config/*.env.example
|
!config/*.env.example
|
||||||
|
|
@ -12,6 +16,10 @@ config/icecast.xml
|
||||||
# runtime data
|
# runtime data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# streamer build artifacts
|
||||||
|
streamer/node_modules/
|
||||||
|
streamer/dist/
|
||||||
|
|
||||||
# OS junk
|
# OS junk
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
206
CLAUDE.md
Normal file
206
CLAUDE.md
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Self-hosted multi-station internet radio at `https://denpa.femboy.page`. Three docker services (Icecast, Liquidsoap, Astro frontend) plus a Caddy host service handle streaming, now-playing metadata, history, and the SPA. Each station is a folder on a Hetzner Storage Box with a `_meta.yml` and audio tracks; adding a station does not require a frontend rebuild.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Caddy (host service, /etc/caddy/Caddyfile)
|
||||||
|
│ denpa.femboy.page
|
||||||
|
┌──────────────────┬──────────┴──────────┬─────────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
/<id>.{mp3,opus} /api/* /now-playing/* /
|
||||||
|
│ │ │ │
|
||||||
|
icecast:12000 frontend:12001 caddy file_server frontend:12001
|
||||||
|
(libretime/ (astro hybrid SSR on /srv/denpa- (astro static)
|
||||||
|
icecast:2.4.4) via node adapter) radio/data
|
||||||
|
▲
|
||||||
|
│ source protocol on the compose network
|
||||||
|
liquidsoap (savonet/liquidsoap:v2.3.3)
|
||||||
|
│ reads /library/<station>/tracks
|
||||||
|
│ writes /now-playing/<station>.json + .history.json
|
||||||
|
▼
|
||||||
|
/mnt/trashbox/denpa-radio/library/ ← Hetzner Storage Box (CIFS, RO bind)
|
||||||
|
```
|
||||||
|
|
||||||
|
Why `/now-playing/*.json` is served by Caddy directly (not the frontend): liquidsoap writes the files atomically, they are tiny static JSON, and serving them through Caddy means a frontend outage does not break now-playing readers (frontend itself fetches them client-side).
|
||||||
|
|
||||||
|
## Library layout (the "by station" promise)
|
||||||
|
|
||||||
|
On the Storage Box, mounted at `/mnt/trashbox/denpa-radio/library/` on summer:
|
||||||
|
|
||||||
|
```
|
||||||
|
<station-id>/ # id must match [a-z0-9-]+
|
||||||
|
├── _meta.yml # name, description, color, tags
|
||||||
|
├── cover.{jpg,png,webp} # optional; served by /api/stations/<id>/cover
|
||||||
|
├── tracks/ # audio files; subdirs supported (e.g. tracks/Volume Alpha/, tracks/Singles/)
|
||||||
|
└── jingles/ # optional, currently unused in v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Liquidsoap's `playlist()` recurses into subdirectories of `tracks/` by default — organize by album/source however you like. Any decoder-supported audio works (flac, mp3, opus, m4a, wav, ogg). Non-audio files in `tracks/` will be picked up and fail to decode (FFmpeg returns no audio stream); keep cover art and other non-audio at the station root, NOT inside `tracks/`.
|
||||||
|
|
||||||
|
Adding a station: mkdir + `_meta.yml` + tracks → edit `config/liquidsoap.liq` to add a new station block following the `# === station: <id> ===` pattern → tar-pipe the script and `docker compose restart liquidsoap` on summer. The frontend's `/api/stations.json` picks up the new entry within ~30s (Cache-Control max-age) without a rebuild. No Caddy reload, no Icecast restart.
|
||||||
|
|
||||||
|
## Deployment target ("summer")
|
||||||
|
|
||||||
|
- Host: `summer.node.femboy.page` (port 32020 for SSH; Forgejo git on 22).
|
||||||
|
- Stack lives at `/srv/denpa-radio/`.
|
||||||
|
- Caddy v2.10 is a system service, NOT containerized; config at `/etc/caddy/Caddyfile`.
|
||||||
|
- Disk constraint: ~33 GB free root; the library never lives on summer (it is on the Storage Box).
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
The `summer` host alias is in `~/.ssh/config` (port 32020, user root) but does NOT specify an `IdentityFile`. Always pass the key explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/keys/devilreef summer '<cmd>'
|
||||||
|
```
|
||||||
|
|
||||||
|
For git pushes (Forgejo on summer, port 22, user `git`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/keys/devilreef -o IdentityAgent=none -o IdentitiesOnly=yes" git push ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transfer (no rsync on Windows Git Bash)
|
||||||
|
|
||||||
|
Use a tar-pipe instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/user/Documents/Projects/personal/denpa-radio
|
||||||
|
tar -cf - <files> | ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/keys/devilreef summer 'cd /srv/denpa-radio && tar -xf -'
|
||||||
|
```
|
||||||
|
|
||||||
|
When piping large binaries through Git Bash, `cat <file> | ssh ...` may corrupt UTF-8-interpreted bytes; use `dd if=<file> bs=1M | ssh ...` if you see that.
|
||||||
|
|
||||||
|
### Compose conventions (summer-server style guide)
|
||||||
|
|
||||||
|
- All services have `restart: always`. (The original style guide says `unless-stopped`, but real crashes recover the same way and `docker kill` does NOT trigger a restart with either policy — only manual `docker start` or a real crash does.)
|
||||||
|
- Public ports bind to `172.17.0.1:<PORT>` (docker bridge IP). Caddy proxies. Never bind to `0.0.0.0`.
|
||||||
|
- Allocated ports: `12000` icecast, `12001` frontend.
|
||||||
|
- Env files in `config/*.env` are gitignored; `config/*.env.example` is committed and copied + edited on summer at deploy time.
|
||||||
|
- Image pinning: databases/streaming infra to patch + distro (e.g. `libretime/icecast:2.4.4`, `savonet/liquidsoap:v2.3.3`).
|
||||||
|
- Healthchecks inside containers must use `127.0.0.1`, not `localhost` (alpine wget/curl resolves `localhost` to `::1` while Node binds IPv4 only).
|
||||||
|
|
||||||
|
## Frontend (`frontend/`)
|
||||||
|
|
||||||
|
Astro 5 with `output: 'static'` + `@astrojs/node` standalone adapter — Astro 5 merged "hybrid" into static, with per-route SSR enabled by `export const prerender = false`. Two React islands; everything else is `.astro` (zero JS).
|
||||||
|
|
||||||
|
Dependencies are NOT installed on the dev machine via Astro CLI — install with `npm install` in `frontend/`. Most CLI invocations in this shell go through the direct binary because `npx <bin>` may fail in the host's Bash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
./node_modules/.bin/astro check
|
||||||
|
./node_modules/.bin/astro build
|
||||||
|
./node_modules/.bin/astro dev --host 127.0.0.1 --port 4321
|
||||||
|
./node_modules/.bin/vitest run # all tests
|
||||||
|
./node_modules/.bin/vitest run src/tests/format.test.ts
|
||||||
|
./node_modules/.bin/eslint src
|
||||||
|
```
|
||||||
|
|
||||||
|
Path aliases `@lib/*`, `@components/*`, `@styles/*` are resolved by both `tsconfig.json` (for editor + Astro) and `vite.config.ts` (for Vitest).
|
||||||
|
|
||||||
|
### Component layout
|
||||||
|
|
||||||
|
- `src/pages/index.astro` is the only page. It calls `listStations(LIBRARY_ROOT)` server-side and passes the result to `<HeroPlayer initialStations={...} client:load />`. `prerender = false` so this happens on each request — adding a new station folder is reflected within the cache window.
|
||||||
|
- `src/components/HeroPlayer.tsx` is the only large interactive island. Owns the `<audio crossorigin="anonymous">` element, all player state, polling, keyboard shortcuts, and localStorage persistence.
|
||||||
|
- `src/components/Spectrum.tsx` (client:visible) connects to the audio element via `playerStore` (in `src/lib/store.ts`) and runs a real WebAudio `AnalyserNode` with a sine-wave fallback when paused or when WebAudio is unavailable.
|
||||||
|
- `src/lib/stations.ts` walks `LIBRARY_ROOT`, reads each `_meta.yml`, returns sorted `Station[]`. Server-only (uses `node:fs/promises`).
|
||||||
|
- `src/pages/api/stations.json.ts` and `src/pages/api/stations/[id]/cover.ts` expose station list + cover bytes. Both have `export const prerender = false`.
|
||||||
|
- Local fixture library at `src/tests/fixtures/library/` lets the dev server run without the Storage Box: `LIBRARY_ROOT=$(pwd)/src/tests/fixtures/library ./node_modules/.bin/astro dev`.
|
||||||
|
|
||||||
|
### SSR hydration discipline
|
||||||
|
|
||||||
|
Anything time-sensitive or persisted (current Date, localStorage reads) is initialized to a deterministic SSR-safe default and updated in `useEffect`. Initializing state with `new Date()` or `localStorage.getItem(...)` in `useState(() => ...)` produces hydration mismatches (#418/#423/#425).
|
||||||
|
|
||||||
|
## Liquidsoap script (`config/liquidsoap.liq`)
|
||||||
|
|
||||||
|
One station block per station:
|
||||||
|
|
||||||
|
```liq
|
||||||
|
minecraft = playlist(reload_mode="watch", mode="randomize", "/library/minecraft/tracks")
|
||||||
|
minecraft.on_track(fun (m) -> begin
|
||||||
|
emit_now_playing("minecraft", m)
|
||||||
|
append_history("minecraft", m)
|
||||||
|
end)
|
||||||
|
minecraft = crossfade(minecraft)
|
||||||
|
minecraft = mksafe(minecraft)
|
||||||
|
output.icecast(%mp3(bitrate=192, stereo=true), host="icecast", port=8000, password=source_pw, mount="/minecraft.mp3", ..., minecraft)
|
||||||
|
output.icecast(%opus(bitrate=96, vbr="constrained", samplerate=48000, channels=2), ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes that have already burned us once:
|
||||||
|
- `request.duration(filename)` returns `float?` (option) in Liquidsoap 2.3 — unwrap with `null.get(default=-1., ...)`. The static `liquidsoap --check` does NOT catch this; it surfaces as a runtime crash and a restart loop.
|
||||||
|
- Decoder-reported `m["duration"]` is empty for FLAC files with embedded cover art; we fall back to `request.duration` in `emit_now_playing`.
|
||||||
|
- `mksafe` belongs AFTER `crossfade`, so silence-fallback survives transitions.
|
||||||
|
- `on_track` is hooked BEFORE `crossfade` so it fires per source track, not per fade segment.
|
||||||
|
- Validate after every change: `tar -cf - config/liquidsoap.liq | ssh ... summer 'cd /srv/denpa-radio && tar -xf -' && ssh ... summer 'docker exec denpa-radio-liquidsoap-1 liquidsoap --check /script.liq'`.
|
||||||
|
|
||||||
|
## Caddy (host)
|
||||||
|
|
||||||
|
`/etc/caddy/Caddyfile` block for `denpa.femboy.page` does:
|
||||||
|
|
||||||
|
- `@stream` regex (`^/[a-z0-9-]+\.(mp3|opus)$`) → icecast `:12000`. Forces station ids to lowercase `[a-z0-9-]+` for routing purposes.
|
||||||
|
- `/status-json.xsl` → icecast (CORS open).
|
||||||
|
- `@nowplaying` regex (`^/now-playing/[a-z0-9-]+(\.history)?\.json$`) → `file_server` over `/srv/denpa-radio/data` with `Cache-Control: no-store`.
|
||||||
|
- everything else → frontend `:12001`.
|
||||||
|
|
||||||
|
Always validate + reload after edits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ... summer 'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common workflows
|
||||||
|
|
||||||
|
**Bring up the stack on summer (first deploy or after a teardown):**
|
||||||
|
```bash
|
||||||
|
ssh ... summer 'cd /srv/denpa-radio && docker compose up -d'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rebuild + restart frontend after a code change:**
|
||||||
|
```bash
|
||||||
|
cd /c/Users/user/Documents/Projects/personal/denpa-radio
|
||||||
|
tar -cf - --exclude='node_modules' --exclude='dist' --exclude='.astro' frontend | \
|
||||||
|
ssh ... summer 'cd /srv/denpa-radio && tar -xf -'
|
||||||
|
ssh ... summer 'cd /srv/denpa-radio && docker compose build frontend && docker compose up -d frontend'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tail logs:**
|
||||||
|
```bash
|
||||||
|
ssh ... summer 'cd /srv/denpa-radio && docker compose logs -f --tail=100 <service>'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Force a track change (when waiting for one to verify changes):**
|
||||||
|
```bash
|
||||||
|
ssh ... summer 'cd /srv/denpa-radio && docker compose restart liquidsoap'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Public smoke tests:**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -D - https://denpa.femboy.page/minecraft.mp3 | head -3 # use GET; HEAD returns 400 from icecast
|
||||||
|
curl -s https://denpa.femboy.page/api/stations.json | python -m json.tool
|
||||||
|
curl -s https://denpa.femboy.page/now-playing/minecraft.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Things to avoid (already learned)
|
||||||
|
|
||||||
|
- Do NOT use `curl -sI` (HEAD) on `*.{mp3,opus}` mounts. Icecast 2.4.4 returns 400 for HEAD on stream mounts. Use `curl -s -o /dev/null -D -`.
|
||||||
|
- Do NOT bind container ports to `0.0.0.0`; always `172.17.0.1:<PORT>:<PORT>`.
|
||||||
|
- Do NOT install `pgrep` expecting it in `savonet/liquidsoap:v2.3.3`; use `pidof`.
|
||||||
|
- Do NOT mount config files at `/etc/icecast2/icecast.xml` for the `libretime/icecast:2.4.4` image — it expects `/etc/icecast.xml` and `/usr/share/icecast/` (no `2`).
|
||||||
|
- Do NOT commit anything under `docs/superpowers/`, `.claude/`, or `.design-prototype/` — those are local-only working notes/handoff bundles (already gitignored).
|
||||||
|
- Do NOT use Windows-style `2>nul`, `del`, `rmdir /s /q` in Bash on this Windows host — see the user's global `CLAUDE.md` for shell-safety rules.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
- `v0.1.0` — radio backend live (Minecraft station, MP3+Opus).
|
||||||
|
- `v0.2.0` — frontend live (cassette deck UI, real WebAudio spectrum, recently-played).
|
||||||
|
|
||||||
|
## Specs and plans
|
||||||
|
|
||||||
|
`docs/superpowers/specs/` and `docs/superpowers/plans/` (gitignored) hold the design docs and implementation plans used to build this. They are not authoritative — the code is — but they are useful when investigating "why did we do it this way".
|
||||||
80
README.md
Normal file
80
README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# denpa-radio
|
||||||
|
|
||||||
|
a tiny independent multi-station internet radio. cassette-deck aesthetic, real audio, no algorithms.
|
||||||
|
|
||||||
|
live at **<https://denpa.femboy.page>**.
|
||||||
|
|
||||||
|
## what's playing
|
||||||
|
|
||||||
|
| station | description | mp3 | opus |
|
||||||
|
| --------- | ---------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| minecraft | C418, Lena Raine, Aaron Cherof — vol α+β | <https://denpa.femboy.page/minecraft.mp3> (192k) | <https://denpa.femboy.page/minecraft.opus> (96k) |
|
||||||
|
|
||||||
|
drop a stream URL into VLC, mpv, foobar, or any internet-radio app.
|
||||||
|
|
||||||
|
## endpoints
|
||||||
|
|
||||||
|
- `/<station>.mp3` — MP3 192 kbps stream
|
||||||
|
- `/<station>.opus` — Opus 96 kbps stream
|
||||||
|
- `/now-playing/<station>.json` — currently playing (artist, title, album, duration, started_at)
|
||||||
|
- `/now-playing/<station>.history.json` — last 50 tracks for the station
|
||||||
|
- `/api/stations.json` — list of all stations with metadata
|
||||||
|
- `/api/stations/<station>/cover` — cover art (when present)
|
||||||
|
- `/status-json.xsl` — raw Icecast status (listener counts, mount info)
|
||||||
|
|
||||||
|
CORS is open on all of the above; build your own scrobbler / dashboard / overlay.
|
||||||
|
|
||||||
|
## architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Caddy (host) ─┬─ /<station>.{mp3,opus}, /status-json.xsl → icecast (libretime/icecast)
|
||||||
|
├─ /now-playing/*.json → caddy file_server
|
||||||
|
├─ /api/* → frontend (astro 5 + react)
|
||||||
|
└─ / → frontend
|
||||||
|
▲
|
||||||
|
└─ liquidsoap (savonet/liquidsoap)
|
||||||
|
│ reads station folders
|
||||||
|
│ writes now-playing JSON
|
||||||
|
▼
|
||||||
|
Hetzner Storage Box (CIFS, mounted RO)
|
||||||
|
```
|
||||||
|
|
||||||
|
three docker containers (icecast, liquidsoap, frontend) and a Caddy host service. each station is a folder on the storage box with a `_meta.yml`, a `tracks/` subdir, and an optional `cover.{jpg,png,webp}`. the frontend reads `_meta.yml` files at request time, so adding a station does not require a rebuild.
|
||||||
|
|
||||||
|
## adding a station
|
||||||
|
|
||||||
|
on the storage box (mounted at `/mnt/trashbox/denpa-radio/library/` on summer):
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p <station-id>/{tracks,jingles}
|
||||||
|
cat > <station-id>/_meta.yml <<EOF
|
||||||
|
name: My Station
|
||||||
|
description: short description
|
||||||
|
color: '#5cae34'
|
||||||
|
tags: [tag1, tag2]
|
||||||
|
EOF
|
||||||
|
# drop audio files into <station-id>/tracks/
|
||||||
|
# subdirs supported: tracks/Volume Alpha/, tracks/Singles/, etc — Liquidsoap recurses
|
||||||
|
# (optional) drop cover.jpg into <station-id>/ (NOT inside tracks/ — would fail to decode)
|
||||||
|
```
|
||||||
|
|
||||||
|
then on summer:
|
||||||
|
|
||||||
|
```
|
||||||
|
# duplicate the existing minecraft station block in /srv/denpa-radio/config/liquidsoap.liq
|
||||||
|
# rename references to your new id, then:
|
||||||
|
docker compose -f /srv/denpa-radio/docker-compose.yml restart liquidsoap
|
||||||
|
```
|
||||||
|
|
||||||
|
the frontend picks up the new station within ~30s. no Caddy reload, no Icecast restart.
|
||||||
|
|
||||||
|
station ids must match `[a-z0-9-]+`.
|
||||||
|
|
||||||
|
## tags
|
||||||
|
|
||||||
|
- **v0.1.0** — radio backend live (Minecraft station, MP3 + Opus).
|
||||||
|
- **v0.2.0** — frontend live (cassette deck UI, real WebAudio spectrum, recently-played).
|
||||||
|
|
||||||
|
## working on this repo
|
||||||
|
|
||||||
|
see [`CLAUDE.md`](./CLAUDE.md) for the full operator's manual: SSH conventions, deploy quirks, frontend stack notes, Liquidsoap gotchas, and the things we've already learned the hard way.
|
||||||
5
config/frontend.env.example
Normal file
5
config/frontend.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
LIBRARY_ROOT=/library
|
||||||
|
DATA_ROOT=/now-playing
|
||||||
|
|
@ -6,27 +6,190 @@ settings.log.level.set(3)
|
||||||
|
|
||||||
source_pw = environment.get(default="", "SOURCE_PW")
|
source_pw = environment.get(default="", "SOURCE_PW")
|
||||||
|
|
||||||
def emit_now_playing(station, m) =
|
# deck-shuffle: full shuffle once, hand out in order, reshuffle on exhaustion.
|
||||||
|
# returns a record with .source (request.dynamic) and .peek (returns next N filenames).
|
||||||
|
def make_deck(dir) =
|
||||||
|
files = ref([])
|
||||||
|
cursor = ref(0)
|
||||||
|
last_played = ref("")
|
||||||
|
|
||||||
|
def reload_and_shuffle() =
|
||||||
|
all = file.ls(dir, recursive=true, absolute=true)
|
||||||
|
audio_exts = ["flac", "mp3", "opus", "m4a", "wav", "ogg"]
|
||||||
|
audio = list.filter(
|
||||||
|
fun(f) -> list.mem(string.case(lower=true, file.extension(leading_dot=false, f)), audio_exts),
|
||||||
|
all
|
||||||
|
)
|
||||||
|
files := list.shuffle(audio)
|
||||||
|
if !last_played != "" and list.length(!files) > 1 and list.nth(default="", !files, 0) == !last_played then
|
||||||
|
files := list.append(
|
||||||
|
[list.nth(default="", !files, 1), list.nth(default="", !files, 0)],
|
||||||
|
list.tl(list.tl(!files))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
cursor := 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_file() =
|
||||||
|
if !cursor >= list.length(!files) then reload_and_shuffle() end
|
||||||
|
if list.length(!files) == 0 then "" else
|
||||||
|
f = list.nth(default="", !files, !cursor)
|
||||||
|
cursor := !cursor + 1
|
||||||
|
last_played := f
|
||||||
|
f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_request() =
|
||||||
|
f = next_file()
|
||||||
|
if f == "" then null() else request.create(f) end
|
||||||
|
end
|
||||||
|
|
||||||
|
def peek(n) =
|
||||||
|
list.init(
|
||||||
|
min(n, list.length(!files) - !cursor),
|
||||||
|
fun(i) -> list.nth(default="", !files, !cursor + i)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
reload_and_shuffle()
|
||||||
|
{ source = request.dynamic(next_request), peek = peek }
|
||||||
|
end
|
||||||
|
|
||||||
|
# filename-based fallbacks for tracks with empty embedded tags.
|
||||||
|
# strips leading "08. " / "12 - " etc. from the file stem.
|
||||||
|
def filename_title(filename) =
|
||||||
|
base = path.basename(filename)
|
||||||
|
ext = file.extension(leading_dot=true, base)
|
||||||
|
stem =
|
||||||
|
if ext != "" then
|
||||||
|
string.sub(base, start=0, length=string.length(base) - string.length(ext))
|
||||||
|
else base end
|
||||||
|
re = regexp("^[0-9]+[. \\-]+ *")
|
||||||
|
re.replace(fun(_) -> "", stem)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filename_album(filename) =
|
||||||
|
path.basename(path.dirname(filename))
|
||||||
|
end
|
||||||
|
|
||||||
|
def or_else(s, fallback) =
|
||||||
|
if s == "" then fallback else s end
|
||||||
|
end
|
||||||
|
|
||||||
|
# walk up from the audio file to find cover.{jpg,png,webp}.
|
||||||
|
# falls back to <station>/cover.* (one level above tracks/).
|
||||||
|
def cover_for(filename) =
|
||||||
|
def find_in(dir) =
|
||||||
|
candidates = ["#{dir}/cover.jpg", "#{dir}/cover.png", "#{dir}/cover.webp"]
|
||||||
|
list.fold(
|
||||||
|
fun(found, c) -> if found == "" and file.exists(c) then c else found end,
|
||||||
|
"",
|
||||||
|
candidates
|
||||||
|
)
|
||||||
|
end
|
||||||
|
album_dir = path.dirname(filename)
|
||||||
|
c1 = find_in(album_dir)
|
||||||
|
if c1 != "" then c1 else
|
||||||
|
tracks_dir = path.dirname(album_dir)
|
||||||
|
station_root = path.dirname(tracks_dir)
|
||||||
|
find_in(station_root)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# build a json object for one upcoming track from its filename.
|
||||||
|
# reads tags via request.metadata; uses request.duration for length.
|
||||||
|
def track_meta_json(filename) =
|
||||||
|
r = request.create(filename)
|
||||||
|
ignore(request.resolve(r))
|
||||||
|
m = request.metadata(r)
|
||||||
|
request.destroy(r)
|
||||||
|
d = null.get(default=-1., request.duration(filename))
|
||||||
|
obj = json()
|
||||||
|
obj.add("title", or_else(m["title"], filename_title(filename)))
|
||||||
|
obj.add("artist", m["artist"])
|
||||||
|
obj.add("album", or_else(m["album"], filename_album(filename)))
|
||||||
|
obj.add("duration", if d > 0. then string(int_of_float(d)) else "" end)
|
||||||
|
obj.add("cover_path", cover_for(filename))
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
|
||||||
|
def emit_now_playing(station, m, upcoming) =
|
||||||
filename = m["filename"]
|
filename = m["filename"]
|
||||||
if filename != "" then
|
if filename != "" then
|
||||||
|
meta_dur = m["duration"]
|
||||||
|
dur_str =
|
||||||
|
if meta_dur != "" then
|
||||||
|
meta_dur
|
||||||
|
else
|
||||||
|
d = null.get(default=-1., request.duration(filename))
|
||||||
|
if d > 0. then string(int_of_float(d)) else "" end
|
||||||
|
end
|
||||||
|
|
||||||
payload = json()
|
payload = json()
|
||||||
payload.add("station", station)
|
payload.add("station", station)
|
||||||
payload.add("artist", m["artist"])
|
payload.add("artist", m["artist"])
|
||||||
payload.add("title", m["title"])
|
payload.add("title", or_else(m["title"], filename_title(filename)))
|
||||||
payload.add("album", m["album"])
|
payload.add("album", or_else(m["album"], filename_album(filename)))
|
||||||
payload.add("filename", filename)
|
payload.add("filename", filename)
|
||||||
|
payload.add("duration", dur_str)
|
||||||
payload.add("started_at", string(int_of_float(time())))
|
payload.add("started_at", string(int_of_float(time())))
|
||||||
file.write(data=payload.stringify(), atomic=true, temp_dir="/now-playing", "/now-playing/#{station}.json")
|
payload.add("cover_path", cover_for(filename))
|
||||||
|
|
||||||
|
up_arr = list.map(track_meta_json, upcoming)
|
||||||
|
payload.add("up_next", up_arr)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
data=payload.stringify(),
|
||||||
|
atomic=true,
|
||||||
|
temp_dir="/now-playing",
|
||||||
|
"/now-playing/#{station}.json"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def append_history(station, m) =
|
||||||
|
filename = m["filename"]
|
||||||
|
if filename != "" then
|
||||||
|
history_path = "/now-playing/#{station}.history.json"
|
||||||
|
entry = {
|
||||||
|
title = m["title"],
|
||||||
|
artist = m["artist"],
|
||||||
|
album = m["album"],
|
||||||
|
filename = filename,
|
||||||
|
started_at = string(int_of_float(time()))
|
||||||
|
}
|
||||||
|
arr =
|
||||||
|
if file.exists(history_path) then
|
||||||
|
existing = file.contents(history_path)
|
||||||
|
try
|
||||||
|
let json.parse (parsed : [{
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
album: string,
|
||||||
|
filename: string,
|
||||||
|
started_at: string
|
||||||
|
}]) = existing
|
||||||
|
parsed
|
||||||
|
catch _ do
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
new_arr = list.add(entry, list.prefix(49, arr))
|
||||||
|
out_str = json.stringify(new_arr)
|
||||||
|
file.write(data=out_str, atomic=true, temp_dir="/now-playing", history_path)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# === station: minecraft ===
|
# === station: minecraft ===
|
||||||
minecraft = playlist(
|
minecraft_deck = make_deck("/library/minecraft/tracks")
|
||||||
reload_mode="watch",
|
minecraft = minecraft_deck.source
|
||||||
mode="randomize",
|
minecraft.on_track(fun (m) -> begin
|
||||||
"/library/minecraft/tracks"
|
emit_now_playing("minecraft", m, minecraft_deck.peek(4))
|
||||||
)
|
append_history("minecraft", m)
|
||||||
minecraft.on_track(fun (m) -> emit_now_playing("minecraft", m))
|
end)
|
||||||
minecraft = crossfade(minecraft)
|
minecraft = crossfade(minecraft)
|
||||||
minecraft = mksafe(minecraft)
|
minecraft = mksafe(minecraft)
|
||||||
|
|
||||||
|
|
@ -51,3 +214,9 @@ output.icecast(
|
||||||
url="https://denpa.femboy.page",
|
url="https://denpa.femboy.page",
|
||||||
minecraft
|
minecraft
|
||||||
)
|
)
|
||||||
|
|
||||||
|
output.url(
|
||||||
|
%ffmpeg(format="s16le", %audio(codec="pcm_s16le", ac=2, ar=48000)),
|
||||||
|
url="tcp://0.0.0.0:9100?listen=1",
|
||||||
|
minecraft
|
||||||
|
)
|
||||||
|
|
|
||||||
25
config/streamer-minecraft-tg.env.example
Normal file
25
config/streamer-minecraft-tg.env.example
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# streamer for the minecraft station, target = telegram rtmp ingest.
|
||||||
|
# copy to streamer-minecraft-tg.env on summer, fill in RTMP_URL.
|
||||||
|
|
||||||
|
STATION=minecraft
|
||||||
|
RTMP_URL=rtmps://dc4-1.rtmp.t.me/s/REPLACE_WITH_KEY
|
||||||
|
LIQUIDSOAP_PCM=tcp://liquidsoap:9100
|
||||||
|
NOW_PLAYING_DIR=/now-playing
|
||||||
|
LIBRARY_DIR=/library
|
||||||
|
|
||||||
|
STYLE=denpa
|
||||||
|
TZ=Asia/Tokyo
|
||||||
|
|
||||||
|
VIDEO_BITRATE=4500k
|
||||||
|
AUDIO_BITRATE=160k
|
||||||
|
FRAMERATE=30
|
||||||
|
RESOLUTION=1920x1080
|
||||||
|
|
||||||
|
STATION_TUNE_IN_URL=denpa.femboy.page
|
||||||
|
# set to false to hide all "denpa.fm" branding + links in the overlay
|
||||||
|
BRANDING=true
|
||||||
|
|
||||||
|
ICECAST_STATUS_URL=http://icecast:8000/status-json.xsl
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
HEALTH_PORT=12010
|
||||||
|
|
@ -45,3 +45,53 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
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
|
||||||
|
|
||||||
|
streamer-minecraft-tg:
|
||||||
|
build: ./streamer
|
||||||
|
image: denpa-radio-streamer:latest
|
||||||
|
restart: always
|
||||||
|
env_file: config/streamer-minecraft-tg.env
|
||||||
|
depends_on:
|
||||||
|
liquidsoap:
|
||||||
|
condition: service_healthy
|
||||||
|
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
|
||||||
|
shm_size: '1gb'
|
||||||
|
ports:
|
||||||
|
- '172.17.0.1:12010:12010'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:12010/health').then(r=>r.json()).then(j=>{if(!j.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
|
||||||
6
frontend/.gitignore
vendored
Normal file
6
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
19
frontend/astro.config.mjs
Normal file
19
frontend/astro.config.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
|
// astro 5 merged 'hybrid' into 'static' + per-route `export const prerender = false`.
|
||||||
|
// the node adapter is required so api endpoints can actually run on demand.
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'static',
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
integrations: [react()],
|
||||||
|
server: {
|
||||||
|
host: process.env.HOST || '0.0.0.0',
|
||||||
|
port: Number(process.env.PORT) || 3000,
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
server: { fs: { strict: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
13
frontend/eslint.config.js
Normal file
13
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'node_modules', '.astro'] },
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
10169
frontend/package-lock.json
generated
Normal file
10169
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "denpa-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"start": "node ./dist/server/entry.mjs",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"check": "astro check",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.0.0",
|
||||||
|
"@astrojs/react": "^4.0.0",
|
||||||
|
"astro": "^5.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"yaml": "^2.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.9",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitest/ui": "^2.0.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"happy-dom": "^15.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/fonts/DotGothic16.woff2
Normal file
BIN
frontend/public/fonts/DotGothic16.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/ReenieBeanie.woff2
Normal file
BIN
frontend/public/fonts/ReenieBeanie.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/VT323.woff2
Normal file
BIN
frontend/public/fonts/VT323.woff2
Normal file
Binary file not shown.
8
frontend/src/components/FooterStrip.astro
Normal file
8
frontend/src/components/FooterStrip.astro
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
---
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-bot">
|
||||||
|
<span>denpa.fm — a tiny independent radio · est. 2026</span>
|
||||||
|
<span><a href="https://denpa.femboy.page/status-json.xsl">icecast status</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
335
frontend/src/components/HeroPlayer.tsx
Normal file
335
frontend/src/components/HeroPlayer.tsx
Normal file
|
|
@ -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<Station[]>(initialStations);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>(initialStations[0]?.id ?? '');
|
||||||
|
const [format, setFormat] = useState<Format>('mp3');
|
||||||
|
const [vol, setVol] = useState<number>(0.67);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [now, setNow] = useState<NowPlaying | null>(null);
|
||||||
|
const [listeners, setListeners] = useState<number | null>(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<HTMLAudioElement | null>(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 <audio> events
|
||||||
|
useEffect(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a) return;
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
const onError = () => setPlaying(false);
|
||||||
|
a.addEventListener('play', onPlay);
|
||||||
|
a.addEventListener('pause', onPause);
|
||||||
|
a.addEventListener('error', onError);
|
||||||
|
return () => {
|
||||||
|
a.removeEventListener('play', onPlay);
|
||||||
|
a.removeEventListener('pause', onPause);
|
||||||
|
a.removeEventListener('error', onError);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// volume sync
|
||||||
|
useEffect(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (a) a.volume = muted ? 0 : vol;
|
||||||
|
lsSet('denpa:vol', String(vol));
|
||||||
|
}, [vol, muted]);
|
||||||
|
|
||||||
|
// persist selections
|
||||||
|
useEffect(() => { lsSet('denpa:station', selectedId); }, [selectedId]);
|
||||||
|
useEffect(() => { lsSet('denpa:format', format); }, [format]);
|
||||||
|
|
||||||
|
// poll now-playing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const fetchNow = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/now-playing/${selectedId}.json`, { signal: ctrl.signal });
|
||||||
|
if (!r.ok) { setNow(null); return; }
|
||||||
|
const np = await r.json() as NowPlaying;
|
||||||
|
setNow(np);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') setNow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchNow();
|
||||||
|
timer = setInterval(fetchNow, POLL_NOW_MS);
|
||||||
|
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
// poll listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const fetchListeners = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/status-json.xsl', { signal: ctrl.signal });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const j = await r.json() as IcecastStatusJson;
|
||||||
|
const sources = j?.icestats?.source;
|
||||||
|
const arr = Array.isArray(sources) ? sources : sources ? [sources] : [];
|
||||||
|
const want = `/${selectedId}.${format}`;
|
||||||
|
const m = arr.find((s) => s.listenurl?.endsWith(want));
|
||||||
|
if (m && typeof m.listeners === 'number') setListeners(m.listeners);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') setListeners(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchListeners();
|
||||||
|
timer = setInterval(fetchListeners, POLL_LISTENERS_MS);
|
||||||
|
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
|
||||||
|
}, [selectedId, format]);
|
||||||
|
|
||||||
|
// elapsed ticker
|
||||||
|
useEffect(() => {
|
||||||
|
if (!now?.started_at) { setElapsed(0); return; }
|
||||||
|
const start = parseInt(now.started_at, 10) * 1000;
|
||||||
|
const update = () => setElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000)));
|
||||||
|
update();
|
||||||
|
const id = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [now?.started_at]);
|
||||||
|
|
||||||
|
// wall clock for header — only ticks after mount so ssr/hydrate match (empty strings)
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
const d = new Date();
|
||||||
|
setClockText({ date: fmtJpDate(d), time: fmtJpTime(d) });
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// keyboard shortcuts
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a) return;
|
||||||
|
if (a.paused) a.play().catch(() => { /* ignore — autoplay etc. */ });
|
||||||
|
else a.pause();
|
||||||
|
}, []);
|
||||||
|
const cycleStation = useCallback((dir: 1 | -1) => {
|
||||||
|
if (!stations.length) return;
|
||||||
|
const i = stations.findIndex((s) => s.id === selectedId);
|
||||||
|
const next = stations[(i + dir + stations.length) % stations.length];
|
||||||
|
if (next) setSelectedId(next.id);
|
||||||
|
}, [stations, selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const tgt = e.target as HTMLElement | null;
|
||||||
|
if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName)) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ': e.preventDefault(); togglePlay(); break;
|
||||||
|
case 'ArrowUp': e.preventDefault(); setVol((v) => Math.min(1, v + 0.05)); break;
|
||||||
|
case 'ArrowDown': e.preventDefault(); setVol((v) => Math.max(0, v - 0.05)); break;
|
||||||
|
case 'ArrowLeft': e.preventDefault(); cycleStation(-1); break;
|
||||||
|
case 'ArrowRight': e.preventDefault(); cycleStation(1); break;
|
||||||
|
case 'm': case 'M': e.preventDefault(); setMuted((m) => !m); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [togglePlay, cycleStation]);
|
||||||
|
|
||||||
|
// copy URL
|
||||||
|
const copyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(streamUrl);
|
||||||
|
setCopyState('ok');
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = streamUrl;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
setCopyState('ok');
|
||||||
|
}
|
||||||
|
setTimeout(() => setCopyState('idle'), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// when station/format changes, reload + maybe play
|
||||||
|
useEffect(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a || !station) return;
|
||||||
|
const wasPlaying = !a.paused;
|
||||||
|
a.src = `${station.mounts[format]}`;
|
||||||
|
a.load();
|
||||||
|
if (wasPlaying) a.play().catch(() => { /* ignore */ });
|
||||||
|
}, [selectedId, format]);
|
||||||
|
|
||||||
|
if (!station) {
|
||||||
|
return <section className="hero"><p>no stations available — add a station folder under <code>library/</code> on the storage box.</p></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = parseInt(now?.duration ?? '', 10);
|
||||||
|
const hasDur = !isNaN(dur) && dur > 0;
|
||||||
|
const pct = hasDur ? Math.min(100, (elapsed / dur) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero-header">
|
||||||
|
<div className="hero-wordmark">
|
||||||
|
<div className="eyebrow">電波 TRANSMISSION</div>
|
||||||
|
<div className="big">denpa.fm</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-tagline">
|
||||||
|
tune in.<br/>tune out.<br/>melt yr brain {'<3'}
|
||||||
|
</div>
|
||||||
|
<div className="hero-status">
|
||||||
|
<div>{clockText.date || ' '}</div>
|
||||||
|
<div>{clockText.time || ' '}</div>
|
||||||
|
<div className="live">[<span className="blink">{playing ? 'REC' : 'OFF'}</span>] {playing ? 'LIVE' : 'PAUSED'} · {listeners ?? '—'} listening</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-body">
|
||||||
|
<aside className="hero-stations">
|
||||||
|
<div className="hero-stations-label">>> 局 / STATIONS</div>
|
||||||
|
{stations.map((s, i) => (
|
||||||
|
<Tape
|
||||||
|
key={s.id}
|
||||||
|
station={s}
|
||||||
|
index={i}
|
||||||
|
active={s.id === selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
listeners={s.id === selectedId ? listeners : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="hero-deck-wrap">
|
||||||
|
<audio ref={audioRef} crossOrigin="anonymous" preload="none" />
|
||||||
|
<div className="hero-deck">
|
||||||
|
<div className="deck-screen">
|
||||||
|
<div className="deck-screen-row">
|
||||||
|
<span>>> NOW PLAYING</span>
|
||||||
|
<span>{format === 'mp3' ? '192k MP3' : '96k OPUS'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="deck-now">{now?.title || '—'}</div>
|
||||||
|
<div className="deck-artist">{now?.artist || '—'}</div>
|
||||||
|
<div className="deck-screen-row">
|
||||||
|
<span>album · {now?.album || '—'}</span>
|
||||||
|
<span>{station.tags.join(' · ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="deck-progress-row">
|
||||||
|
<span className="deck-time">{fmtMs(elapsed)}</span>
|
||||||
|
<div className="deck-progress">
|
||||||
|
{hasDur ? <div className="deck-progress-fill" style={{ width: pct + '%' }} /> : null}
|
||||||
|
</div>
|
||||||
|
<span className="deck-time">{hasDur ? fmtMs(dur) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="deck-reels">
|
||||||
|
<div className={`reel ${playing ? 'spinning' : ''}`} />
|
||||||
|
<div className="reel-strip" />
|
||||||
|
<div className={`reel ${playing ? 'spinning' : ''}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spectrum />
|
||||||
|
|
||||||
|
<div className="deck-controls">
|
||||||
|
<button className="deck-btn play" onClick={togglePlay} type="button">
|
||||||
|
{playing ? '|| PAUSE' : '>> PLAY'}
|
||||||
|
</button>
|
||||||
|
<button className="deck-btn format" data-active={format === 'mp3'} onClick={() => setFormat('mp3')} type="button">MP3</button>
|
||||||
|
<button className="deck-btn format" data-active={format === 'opus'} onClick={() => setFormat('opus')} type="button">OPUS</button>
|
||||||
|
<div className="deck-vol">
|
||||||
|
<label>VOL</label>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={1} step={0.01} value={muted ? 0 : vol}
|
||||||
|
onChange={(e) => { setVol(parseFloat(e.target.value)); setMuted(false); }}
|
||||||
|
/>
|
||||||
|
<span className="deck-vol-pct">{Math.round((muted ? 0 : vol) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-footer">
|
||||||
|
<button className="share-btn" onClick={copyUrl} type="button">
|
||||||
|
{copyState === 'ok' ? '[ok] copied!' : '[ ] copy stream URL'}
|
||||||
|
</button>
|
||||||
|
<div className="ticker">
|
||||||
|
<div className="ticker-inner">
|
||||||
|
※ no ads · no algorithms · just signal ※ space=play ←→=switch ↑↓=vol m=mute ※
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/components/HistoryCard.astro
Normal file
12
frontend/src/components/HistoryCard.astro
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
import { HistoryList } from './HistoryList';
|
||||||
|
interface Props { station: string; }
|
||||||
|
const { station } = Astro.props;
|
||||||
|
---
|
||||||
|
<div class="card history">
|
||||||
|
<div class="full-secthead">
|
||||||
|
<div class="full-secthead-jp">再生履歴</div>
|
||||||
|
<div class="full-secthead-en">// recently played</div>
|
||||||
|
</div>
|
||||||
|
<HistoryList station={station} client:visible />
|
||||||
|
</div>
|
||||||
48
frontend/src/components/HistoryList.tsx
Normal file
48
frontend/src/components/HistoryList.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { HistoryEntry } from '@lib/types';
|
||||||
|
import { fmtRelative } from '@lib/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
station: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryList({ station }: Props) {
|
||||||
|
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/now-playing/${station}.history.json`, { signal: ctrl.signal });
|
||||||
|
if (!r.ok) { setEntries([]); return; }
|
||||||
|
const arr = await r.json() as HistoryEntry[];
|
||||||
|
setEntries(Array.isArray(arr) ? arr : []);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') setEntries([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const id = setInterval(load, 15_000);
|
||||||
|
const tick = setInterval(() => setNow(new Date()), 30_000);
|
||||||
|
return () => { ctrl.abort(); clearInterval(id); clearInterval(tick); };
|
||||||
|
}, [station]);
|
||||||
|
|
||||||
|
if (entries === null) return <div className="history-empty">loading…</div>;
|
||||||
|
if (!entries.length) return <div className="history-empty">no recent tracks</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-list">
|
||||||
|
{entries.slice(0, 10).map((e, i) => {
|
||||||
|
const start = new Date(parseInt(e.started_at, 10) * 1000);
|
||||||
|
return (
|
||||||
|
<div key={`${e.started_at}-${i}`} className="history-row">
|
||||||
|
<span className="t">{fmtRelative(start, now)}</span>
|
||||||
|
<span className="title">{e.title || '—'}</span>
|
||||||
|
<span className="artist">{e.artist || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/ListenWaysCard.astro
Normal file
28
frontend/src/components/ListenWaysCard.astro
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
const ORIGIN = 'https://denpa.femboy.page';
|
||||||
|
interface Props { stationId: string; }
|
||||||
|
const { stationId } = Astro.props;
|
||||||
|
---
|
||||||
|
<div class="card listen-ways">
|
||||||
|
<div class="full-secthead">
|
||||||
|
<div class="full-secthead-jp">他の聴き方</div>
|
||||||
|
<div class="full-secthead-en">// other ways to listen</div>
|
||||||
|
</div>
|
||||||
|
<div class="listen-ways-list">
|
||||||
|
<div class="listen-way">
|
||||||
|
<div class="lw-label">DIRECT STREAM (MP3)</div>
|
||||||
|
<code class="lw-url">{ORIGIN}/{stationId}.mp3</code>
|
||||||
|
<div class="lw-hint">drop into VLC, mpv, foobar, etc.</div>
|
||||||
|
</div>
|
||||||
|
<div class="listen-way">
|
||||||
|
<div class="lw-label">DIRECT STREAM (OPUS)</div>
|
||||||
|
<code class="lw-url">{ORIGIN}/{stationId}.opus</code>
|
||||||
|
<div class="lw-hint">smaller, better quality. modern players only.</div>
|
||||||
|
</div>
|
||||||
|
<div class="listen-way">
|
||||||
|
<div class="lw-label">METADATA JSON</div>
|
||||||
|
<code class="lw-url">{ORIGIN}/now-playing/{stationId}.json</code>
|
||||||
|
<div class="lw-hint">build your own scrobbler.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
104
frontend/src/components/Spectrum.tsx
Normal file
104
frontend/src/components/Spectrum.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { playerStore } from '@lib/store';
|
||||||
|
|
||||||
|
const BARS = 36;
|
||||||
|
|
||||||
|
export function Spectrum() {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const barRefs = useRef<HTMLDivElement[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
let analyser: AnalyserNode | null = null;
|
||||||
|
let buf: Uint8Array<ArrayBuffer> | null = null;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
const tryConnect = () => {
|
||||||
|
if (connected) return;
|
||||||
|
const audio = playerStore.getAudio();
|
||||||
|
if (!audio) return;
|
||||||
|
try {
|
||||||
|
if (!ctx) {
|
||||||
|
const Ctor = window.AudioContext
|
||||||
|
?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!Ctor) throw new Error('no AudioContext');
|
||||||
|
ctx = new Ctor();
|
||||||
|
}
|
||||||
|
const src = ctx.createMediaElementSource(audio);
|
||||||
|
analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 128;
|
||||||
|
src.connect(analyser);
|
||||||
|
analyser.connect(ctx.destination);
|
||||||
|
buf = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
|
||||||
|
connected = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[spectrum] webaudio unavailable, using fake', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeFrame = (t: number) => {
|
||||||
|
for (let i = 0; i < BARS; i++) {
|
||||||
|
const a = Math.sin(t * 0.0021 + i * 0.7) * 0.5 + 0.5;
|
||||||
|
const f = i / BARS;
|
||||||
|
const env = 0.4 + 0.6 * Math.exp(-f * 2.2);
|
||||||
|
const v = a * env * 0.3;
|
||||||
|
const el = barRefs.current[i];
|
||||||
|
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const realFrame = () => {
|
||||||
|
if (!analyser || !buf) return;
|
||||||
|
analyser.getByteFrequencyData(buf);
|
||||||
|
for (let i = 0; i < BARS; i++) {
|
||||||
|
const v = (buf[i] ?? 0) / 255;
|
||||||
|
const el = barRefs.current[i];
|
||||||
|
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (t: number) => {
|
||||||
|
tryConnect();
|
||||||
|
const audio = playerStore.getAudio();
|
||||||
|
if (connected && audio && !audio.paused) {
|
||||||
|
if (ctx?.state === 'suspended') {
|
||||||
|
void ctx.resume();
|
||||||
|
}
|
||||||
|
realFrame();
|
||||||
|
} else {
|
||||||
|
fakeFrame(t);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
const unsub = playerStore.subscribe(() => {
|
||||||
|
tryConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
unsub();
|
||||||
|
try {
|
||||||
|
void ctx?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="deck-spectrum" ref={containerRef}>
|
||||||
|
{Array.from({ length: BARS }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="spec-bar"
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) barRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/Tape.tsx
Normal file
30
frontend/src/components/Tape.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { Station } from '@lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
station: Station;
|
||||||
|
index: number;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
listeners?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tape({ station, index, active, onSelect, listeners }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`tape ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(station.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="tape-num">CH.{String(index + 1).padStart(2, '0')}</div>
|
||||||
|
<div className="tape-name">{station.name}</div>
|
||||||
|
{station.tags.length > 0 ? (
|
||||||
|
<div className="tape-jp">{station.tags.slice(0, 2).join(' · ')}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="tape-holes"><span /><span /></div>
|
||||||
|
<div className="tape-meta">
|
||||||
|
<span>{station.mounts.mp3.replace(/^\//, '')}</span>
|
||||||
|
<span>{listeners == null ? '— ppl' : `${listeners} ppl`}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/TopNav.astro
Normal file
9
frontend/src/components/TopNav.astro
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
---
|
||||||
|
<nav class="topnav">
|
||||||
|
<span class="topnav-brand">電波 / denpa.fm</span>
|
||||||
|
<div class="topnav-links">
|
||||||
|
<a href="#listen">listen</a>
|
||||||
|
<a href="#about">about</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
9
frontend/src/env.d.ts
vendored
Normal file
9
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
LIBRARY_ROOT?: string;
|
||||||
|
DATA_ROOT?: string;
|
||||||
|
PUBLIC_ORIGIN?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/lib/format.ts
Normal file
28
frontend/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export function fmtMs(seconds: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${m}:${String(r).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRelative(d: Date, now: Date = new Date()): string {
|
||||||
|
const ms = now.getTime() - d.getTime();
|
||||||
|
if (ms < 5_000) return 'now';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtJpDate(d: Date): string {
|
||||||
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtJpTime(d: Date): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
86
frontend/src/lib/stations.ts
Normal file
86
frontend/src/lib/stations.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { readdir, readFile, stat, access } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
import type { Station } from './types.ts';
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9-]+$/;
|
||||||
|
const COVER_NAMES = ['cover.jpg', 'cover.png', 'cover.webp'];
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMeta(libraryRoot: string, id: string): Promise<Station | null> {
|
||||||
|
if (!ID_RE.test(id)) return null;
|
||||||
|
const dir = path.join(libraryRoot, id);
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(path.join(dir, '_meta.yml'), 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = parseYaml(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[stations] malformed _meta.yml for ${id}:`, (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
console.warn(`[stations] _meta.yml for ${id} is not an object`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const m = parsed as Record<string, unknown>;
|
||||||
|
const name = typeof m.name === 'string' ? m.name : null;
|
||||||
|
const description = typeof m.description === 'string' ? m.description : '';
|
||||||
|
const color = typeof m.color === 'string' ? m.color : '#888';
|
||||||
|
const tags = Array.isArray(m.tags) ? m.tags.filter((t): t is string => typeof t === 'string') : [];
|
||||||
|
if (!name) {
|
||||||
|
console.warn(`[stations] _meta.yml for ${id} missing 'name'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let cover: string | null = null;
|
||||||
|
for (const c of COVER_NAMES) {
|
||||||
|
if (await fileExists(path.join(dir, c))) {
|
||||||
|
cover = `/api/stations/${id}/cover`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
tags,
|
||||||
|
mounts: { mp3: `/${id}.mp3`, opus: `/${id}.opus` },
|
||||||
|
cover,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStations(libraryRoot: string): Promise<Station[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(libraryRoot);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: Station[] = [];
|
||||||
|
for (const id of entries.sort()) {
|
||||||
|
const full = path.join(libraryRoot, id);
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
s = await stat(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!s.isDirectory()) continue;
|
||||||
|
const meta = await readMeta(libraryRoot, id);
|
||||||
|
if (meta) out.push(meta);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
18
frontend/src/lib/store.ts
Normal file
18
frontend/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
let audioRef: HTMLAudioElement | null = null;
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
|
||||||
|
export const playerStore = {
|
||||||
|
setAudio(el: HTMLAudioElement | null) {
|
||||||
|
audioRef = el;
|
||||||
|
subs.forEach((fn) => fn());
|
||||||
|
},
|
||||||
|
getAudio(): HTMLAudioElement | null {
|
||||||
|
return audioRef;
|
||||||
|
},
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
subs.add(fn);
|
||||||
|
return () => {
|
||||||
|
subs.delete(fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
41
frontend/src/lib/types.ts
Normal file
41
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface Station {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
tags: string[];
|
||||||
|
mounts: { mp3: string; opus: string };
|
||||||
|
cover: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NowPlaying {
|
||||||
|
station: string;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
album: string;
|
||||||
|
filename: string;
|
||||||
|
duration: string;
|
||||||
|
started_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
filename: string;
|
||||||
|
started_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcecastSourceJson {
|
||||||
|
listenurl: string;
|
||||||
|
server_name?: string;
|
||||||
|
server_type?: string;
|
||||||
|
listeners?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcecastStatusJson {
|
||||||
|
icestats: {
|
||||||
|
host?: string;
|
||||||
|
source?: IcecastSourceJson | IcecastSourceJson[];
|
||||||
|
};
|
||||||
|
}
|
||||||
17
frontend/src/pages/api/stations.json.ts
Normal file
17
frontend/src/pages/api/stations.json.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { listStations } from '@lib/stations';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const root = process.env.LIBRARY_ROOT ?? '/library';
|
||||||
|
const stations = await listStations(root);
|
||||||
|
return new Response(JSON.stringify(stations), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'max-age=30, public',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
37
frontend/src/pages/api/stations/[id]/cover.ts
Normal file
37
frontend/src/pages/api/stations/[id]/cover.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { access, readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9-]+$/;
|
||||||
|
const CANDIDATES: Array<[string, string]> = [
|
||||||
|
['cover.jpg', 'image/jpeg'],
|
||||||
|
['cover.png', 'image/png'],
|
||||||
|
['cover.webp', 'image/webp'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
if (!ID_RE.test(id)) {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
const root = process.env.LIBRARY_ROOT ?? '/library';
|
||||||
|
for (const [name, mime] of CANDIDATES) {
|
||||||
|
const p = path.join(root, id, name);
|
||||||
|
try {
|
||||||
|
await access(p);
|
||||||
|
const buf = await readFile(p);
|
||||||
|
return new Response(new Uint8Array(buf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mime,
|
||||||
|
'Cache-Control': 'max-age=300, public',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
};
|
||||||
44
frontend/src/pages/index.astro
Normal file
44
frontend/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
import '@styles/tokens.css';
|
||||||
|
import '@styles/global.css';
|
||||||
|
import '@styles/components/hero.css';
|
||||||
|
import '@styles/components/deck.css';
|
||||||
|
import '@styles/components/spectrum.css';
|
||||||
|
import '@styles/components/tape.css';
|
||||||
|
import '@styles/components/history-card.css';
|
||||||
|
import '@styles/components/listen-ways-card.css';
|
||||||
|
import '@styles/components/footer.css';
|
||||||
|
|
||||||
|
import TopNav from '@components/TopNav.astro';
|
||||||
|
import HistoryCard from '@components/HistoryCard.astro';
|
||||||
|
import ListenWaysCard from '@components/ListenWaysCard.astro';
|
||||||
|
import FooterStrip from '@components/FooterStrip.astro';
|
||||||
|
import { HeroPlayer } from '@components/HeroPlayer';
|
||||||
|
import { listStations } from '@lib/stations';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const root = process.env.LIBRARY_ROOT ?? '/library';
|
||||||
|
const initialStations = await listStations(root);
|
||||||
|
const firstStation = initialStations[0]?.id ?? '';
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>denpa.fm // 電波</title>
|
||||||
|
<meta name="description" content="denpa.fm — a tiny independent radio." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<TopNav />
|
||||||
|
<HeroPlayer initialStations={initialStations} client:load />
|
||||||
|
<div id="listen" class="row-grid two">
|
||||||
|
{firstStation ? <HistoryCard station={firstStation} /> : <div />}
|
||||||
|
{firstStation ? <ListenWaysCard stationId={firstStation} /> : <div />}
|
||||||
|
</div>
|
||||||
|
<FooterStrip />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
95
frontend/src/styles/components/deck.css
Normal file
95
frontend/src/styles/components/deck.css
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
.hero-deck {
|
||||||
|
background: linear-gradient(180deg, var(--cream) 0%, #f5e0c4 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 24px;
|
||||||
|
border: 3px solid var(--ink);
|
||||||
|
box-shadow: 8px 8px 0 var(--pink), 16px 16px 0 var(--cyan);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero-deck::before {
|
||||||
|
content: ""; position: absolute; inset: 5px;
|
||||||
|
border: 1px dashed #0a041044; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-screen {
|
||||||
|
background: var(--ink); color: var(--green);
|
||||||
|
font-family: var(--f-mono); font-size: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 2px inset var(--ink);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.deck-screen::after {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px);
|
||||||
|
}
|
||||||
|
.deck-screen-row { display: flex; justify-content: space-between; opacity: 0.75; font-size: 12px; }
|
||||||
|
.deck-now {
|
||||||
|
font-family: var(--f-pixel); font-size: 28px; color: var(--cream);
|
||||||
|
line-height: 1.1; margin: 6px 0 2px;
|
||||||
|
text-shadow: 0 0 10px #5eff9b80;
|
||||||
|
}
|
||||||
|
.deck-artist { font-size: 16px; color: var(--cyan); margin-bottom: 8px; }
|
||||||
|
.deck-progress-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.deck-time { font-size: 12px; color: var(--cyan); min-width: 36px; }
|
||||||
|
.deck-progress { flex: 1; height: 4px; background: #5eff9b22; border: 1px solid #5eff9b55; }
|
||||||
|
.deck-progress-fill { height: 100%; background: var(--green); transition: width 200ms linear; }
|
||||||
|
|
||||||
|
.deck-reels {
|
||||||
|
display: flex; align-items: center; justify-content: space-around;
|
||||||
|
gap: 14px; margin-top: 18px;
|
||||||
|
padding: 14px; background: var(--ink); border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.reel {
|
||||||
|
width: 110px; height: 110px; border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, var(--cream) 0 16px, transparent 16px),
|
||||||
|
conic-gradient(from 0deg, var(--plum-2) 0 25%, var(--plum) 25% 50%, var(--plum-2) 50% 75%, var(--plum) 75%);
|
||||||
|
border: 2px solid var(--cream);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.reel::before {
|
||||||
|
content: ""; position: absolute; inset: 7px; border-radius: 50%;
|
||||||
|
border: 1px dashed #fff4e833;
|
||||||
|
}
|
||||||
|
.reel.spinning { animation: spin 1.6s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.reel-strip {
|
||||||
|
flex: 1; height: 7px;
|
||||||
|
background: linear-gradient(90deg, var(--cream) 0%, #d4a578 50%, var(--cream) 100%);
|
||||||
|
border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-controls {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.deck-btn {
|
||||||
|
font-family: var(--f-pixel); font-size: 16px;
|
||||||
|
background: var(--pink); color: var(--cream);
|
||||||
|
border: 2px solid var(--ink); padding: 10px 14px;
|
||||||
|
cursor: pointer; box-shadow: 3px 3px 0 var(--ink);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: transform 100ms, box-shadow 100ms;
|
||||||
|
}
|
||||||
|
.deck-btn:hover { transform: translate(-1px,-1px); box-shadow: 4px 4px 0 var(--ink); }
|
||||||
|
.deck-btn:active { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
|
||||||
|
.deck-btn.play { font-size: 20px; padding: 16px 24px; background: var(--cyan); color: var(--ink); }
|
||||||
|
.deck-btn.format { background: var(--cream); color: var(--ink); }
|
||||||
|
.deck-btn.format[data-active=true] { background: var(--lemon); }
|
||||||
|
|
||||||
|
.deck-vol {
|
||||||
|
flex: 1; min-width: 200px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: var(--ink); padding: 10px 14px; border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.deck-vol label { font-family: var(--f-pixel); font-size: 13px; color: var(--lemon); letter-spacing: 1.5px; }
|
||||||
|
.deck-vol input { flex: 1; accent-color: var(--pink); }
|
||||||
|
.deck-vol-pct { font-family: var(--f-mono); font-size: 13px; color: var(--cyan); min-width: 38px; text-align: right; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.reel { width: 80px; height: 80px; }
|
||||||
|
.deck-vol { min-width: 100%; }
|
||||||
|
}
|
||||||
9
frontend/src/styles/components/footer.css
Normal file
9
frontend/src/styles/components/footer.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.footer {
|
||||||
|
margin-top: 12px; padding: 30px 0 24px;
|
||||||
|
border-top: 2px dashed #ff3ea566;
|
||||||
|
}
|
||||||
|
.footer-bot {
|
||||||
|
display: flex; justify-content: space-between; gap: 14px;
|
||||||
|
font-family: var(--f-mono); font-size: 12px; color: #fff4e8aa;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
106
frontend/src/styles/components/hero.css
Normal file
106
frontend/src/styles/components/hero.css
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
.topnav {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-family: var(--f-pixel); font-size: 13px; letter-spacing: 2px;
|
||||||
|
padding: 6px 0 18px; border-bottom: 1px dashed #ff3ea533;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap; gap: 10px;
|
||||||
|
}
|
||||||
|
.topnav-brand { color: var(--pink); }
|
||||||
|
.topnav-links { display: flex; gap: 18px; }
|
||||||
|
.topnav-links a { color: var(--cream); }
|
||||||
|
.topnav-links a:hover { color: var(--lemon); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 30px 32px 36px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 0%, #ff3ea522 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse at 80% 100%, #5ef7ff1a 0%, transparent 55%),
|
||||||
|
var(--plum);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
margin-bottom: 36px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 24px; margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.hero-wordmark .eyebrow {
|
||||||
|
font-family: var(--f-pixel); font-size: 16px; letter-spacing: 4px; color: var(--pink);
|
||||||
|
}
|
||||||
|
.hero-wordmark .big {
|
||||||
|
font-family: var(--f-pixel);
|
||||||
|
font-size: clamp(48px, 7vw, 88px);
|
||||||
|
line-height: 0.9; letter-spacing: 0; margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 0.12em;
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, var(--cream) 0%, var(--cream) 50%, var(--pink) 50%, var(--pink) 100%);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
filter: drop-shadow(0 0 8px #ff3ea580);
|
||||||
|
}
|
||||||
|
.hero-tagline {
|
||||||
|
font-family: var(--f-hand); font-size: 26px;
|
||||||
|
color: var(--cream); transform: rotate(-2deg); line-height: 1.05;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero-status {
|
||||||
|
font-family: var(--f-mono); font-size: 13px;
|
||||||
|
color: var(--cyan); text-align: right; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.hero-status .live { color: var(--pink); }
|
||||||
|
.hero-status .blink { animation: blink 1s steps(2) infinite; }
|
||||||
|
@keyframes blink { 50% { opacity: 0.15; } }
|
||||||
|
|
||||||
|
.hero-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero-stations { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.hero-stations-label {
|
||||||
|
font-family: var(--f-pixel); color: var(--lemon);
|
||||||
|
font-size: 14px; letter-spacing: 2px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--ink); border: 2px solid var(--lemon);
|
||||||
|
align-self: flex-start; transform: rotate(-2deg);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-deck-wrap { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||||
|
.hero-footer { display: flex; gap: 12px; align-items: stretch; }
|
||||||
|
.share-btn {
|
||||||
|
font-family: var(--f-mono); font-size: 13px;
|
||||||
|
background: var(--ink); color: var(--cyan);
|
||||||
|
padding: 10px 14px; border: 1.5px dashed #5ef7ff66;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.share-btn:hover { border-style: solid; border-color: var(--cyan); }
|
||||||
|
.ticker {
|
||||||
|
flex: 1; overflow: hidden; white-space: nowrap;
|
||||||
|
background: var(--ink); padding: 10px 0;
|
||||||
|
border-top: 1px dashed #ff3ea566; border-bottom: 1px dashed #ff3ea566;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.ticker-inner {
|
||||||
|
display: inline-block; padding-left: 100%;
|
||||||
|
animation: marq 30s linear infinite;
|
||||||
|
color: var(--pink); font-family: var(--f-mono); font-size: 13px; letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
@keyframes marq { to { transform: translateX(-100%); } }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.hero-body { grid-template-columns: 1fr; }
|
||||||
|
.hero-stations { display: grid; grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page { padding: 14px; }
|
||||||
|
.hero { padding: 20px 16px 24px; }
|
||||||
|
.hero-header { grid-template-columns: 1fr; gap: 14px; }
|
||||||
|
.hero-tagline { text-align: left; }
|
||||||
|
.hero-status { text-align: left; }
|
||||||
|
.hero-stations { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
28
frontend/src/styles/components/history-card.css
Normal file
28
frontend/src/styles/components/history-card.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.full-secthead { margin-bottom: 14px; }
|
||||||
|
.full-secthead-jp { font-family: var(--f-pixel); font-size: 18px; color: var(--lemon); letter-spacing: 4px; }
|
||||||
|
.full-secthead-en { font-family: var(--f-pixel); font-size: 22px; color: var(--cream); letter-spacing: 1px; margin-top: 2px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--plum);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
padding: 22px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: ""; position: absolute; inset: 4px; border: 1px dashed #ff3ea522; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list { display: flex; flex-direction: column; }
|
||||||
|
.history-row {
|
||||||
|
display: grid; grid-template-columns: 50px 1fr auto;
|
||||||
|
gap: 12px; padding: 8px 4px;
|
||||||
|
border-bottom: 1px dashed #ff3ea522;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.history-row:last-child { border-bottom: none; }
|
||||||
|
.history-row .t { font-family: var(--f-mono); color: var(--cyan); }
|
||||||
|
.history-row .title { font-family: var(--f-pixel); font-size: 14px; color: var(--cream); }
|
||||||
|
.history-row .artist { font-family: var(--f-mono); color: #fff4e8aa; font-size: 12px; }
|
||||||
|
.history-empty {
|
||||||
|
font-family: var(--f-mono); font-size: 13px; color: #fff4e8aa; padding: 8px 4px;
|
||||||
|
}
|
||||||
20
frontend/src/styles/components/listen-ways-card.css
Normal file
20
frontend/src/styles/components/listen-ways-card.css
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
.listen-ways-list { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.listen-way {
|
||||||
|
border: 1.5px dashed #5ef7ff44;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0a04108c;
|
||||||
|
}
|
||||||
|
.lw-label { font-family: var(--f-pixel); font-size: 12px; color: var(--lemon); letter-spacing: 2px; margin-bottom: 4px; }
|
||||||
|
.lw-url {
|
||||||
|
display: block; font-family: var(--f-mono); font-size: 13px;
|
||||||
|
color: var(--cyan); background: var(--ink);
|
||||||
|
padding: 6px 8px; word-break: break-all;
|
||||||
|
}
|
||||||
|
.lw-hint { font-family: var(--f-mono); font-size: 11px; color: #fff4e866; margin-top: 6px; }
|
||||||
|
|
||||||
|
.row-grid { display: grid; gap: 24px; margin-bottom: 36px; }
|
||||||
|
.row-grid.two { grid-template-columns: 1.2fr 1fr; }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.row-grid.two { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
10
frontend/src/styles/components/spectrum.css
Normal file
10
frontend/src/styles/components/spectrum.css
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.deck-spectrum {
|
||||||
|
display: flex; align-items: flex-end; gap: 2px;
|
||||||
|
height: 80px; margin-top: 18px;
|
||||||
|
padding: 10px; background: var(--ink); border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.spec-bar {
|
||||||
|
flex: 1; min-height: 3px;
|
||||||
|
background: linear-gradient(180deg, var(--pink) 0%, var(--lemon) 50%, var(--green) 100%);
|
||||||
|
transition: height 80ms linear;
|
||||||
|
}
|
||||||
23
frontend/src/styles/components/tape.css
Normal file
23
frontend/src/styles/components/tape.css
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.tape {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
background: var(--cream); color: var(--ink);
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
box-shadow: 3px 3px 0 var(--ink);
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: transform 120ms ease-out, box-shadow 120ms ease-out;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tape:hover { transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); }
|
||||||
|
.tape.active {
|
||||||
|
background: var(--pink); color: var(--cream);
|
||||||
|
transform: translate(-3px,-3px) rotate(-1deg);
|
||||||
|
box-shadow: 6px 6px 0 var(--lemon);
|
||||||
|
}
|
||||||
|
.tape-num { font-family: var(--f-mono); font-size: 10px; opacity: 0.65; letter-spacing: 1.5px; }
|
||||||
|
.tape-name { font-family: var(--f-pixel); font-size: 16px; line-height: 1.1; margin-top: 2px; }
|
||||||
|
.tape-jp { font-family: var(--f-pixel); font-size: 11px; opacity: 0.85; letter-spacing: 1.5px; margin-top: 2px; }
|
||||||
|
.tape-meta { font-family: var(--f-mono); font-size: 10px; margin-top: 6px; display: flex; justify-content: space-between; opacity: 0.75; }
|
||||||
|
.tape-holes { display: flex; gap: 4px; margin-top: 6px; }
|
||||||
|
.tape-holes span { width: 14px; height: 14px; border-radius: 50%; background: var(--ink); box-shadow: inset 0 0 0 3px var(--cream); }
|
||||||
|
.tape.active .tape-holes span { box-shadow: inset 0 0 0 3px var(--pink); }
|
||||||
50
frontend/src/styles/global.css
Normal file
50
frontend/src/styles/global.css
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: "DotGothic16";
|
||||||
|
src: url("/fonts/DotGothic16.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "VT323";
|
||||||
|
src: url("/fonts/VT323.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "ReenieBeanie";
|
||||||
|
src: url("/fonts/ReenieBeanie.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 0%, #ff3ea522 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse at 80% 30%, #5ef7ff14 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse at 50% 100%, #a78bff22 0%, transparent 50%),
|
||||||
|
var(--plum);
|
||||||
|
color: var(--cream);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-variant-emoji: text;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 100;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.07; mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 99;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
a { color: var(--cyan); text-decoration: none; }
|
||||||
|
a:hover { color: var(--cream); text-decoration: underline; }
|
||||||
|
::selection { background: var(--pink); color: var(--ink); }
|
||||||
|
|
||||||
|
.page { max-width: 1200px; margin: 0 auto; padding: 24px 28px 0; position: relative; z-index: 1; }
|
||||||
14
frontend/src/styles/tokens.css
Normal file
14
frontend/src/styles/tokens.css
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
:root {
|
||||||
|
--f-pixel: "DotGothic16", "VT323", monospace;
|
||||||
|
--f-mono: "VT323", ui-monospace, monospace;
|
||||||
|
--f-hand: "ReenieBeanie", cursive;
|
||||||
|
--pink: #ff3ea5;
|
||||||
|
--cyan: #5ef7ff;
|
||||||
|
--lemon: #ffe24a;
|
||||||
|
--lav: #a78bff;
|
||||||
|
--green: #5eff9b;
|
||||||
|
--cream: #fff4e8;
|
||||||
|
--ink: #0a0410;
|
||||||
|
--plum: #1a0820;
|
||||||
|
--plum-2: #2a1030;
|
||||||
|
}
|
||||||
4
frontend/src/tests/fixtures/library/alpha/_meta.yml
vendored
Normal file
4
frontend/src/tests/fixtures/library/alpha/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: Alpha
|
||||||
|
description: alpha station
|
||||||
|
color: '#ff0000'
|
||||||
|
tags: [test, alpha]
|
||||||
2
frontend/src/tests/fixtures/library/bad-yaml/_meta.yml
vendored
Normal file
2
frontend/src/tests/fixtures/library/bad-yaml/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
name: "unterminated
|
||||||
|
description: never closes
|
||||||
4
frontend/src/tests/fixtures/library/beta/_meta.yml
vendored
Normal file
4
frontend/src/tests/fixtures/library/beta/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: Beta
|
||||||
|
description: beta station
|
||||||
|
color: '#00ff00'
|
||||||
|
tags: [test, beta]
|
||||||
1
frontend/src/tests/fixtures/library/beta/cover.jpg
vendored
Normal file
1
frontend/src/tests/fixtures/library/beta/cover.jpg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fake cover
|
||||||
0
frontend/src/tests/fixtures/library/empty/.gitkeep
vendored
Normal file
0
frontend/src/tests/fixtures/library/empty/.gitkeep
vendored
Normal file
61
frontend/src/tests/format.test.ts
Normal file
61
frontend/src/tests/format.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fmtMs, fmtRelative, fmtJpDate, fmtJpTime } from '@lib/format';
|
||||||
|
|
||||||
|
describe('fmtMs', () => {
|
||||||
|
it('formats zero as 0:00', () => {
|
||||||
|
expect(fmtMs(0)).toBe('0:00');
|
||||||
|
});
|
||||||
|
it('pads seconds to two digits', () => {
|
||||||
|
expect(fmtMs(7)).toBe('0:07');
|
||||||
|
});
|
||||||
|
it('formats 215 as 3:35', () => {
|
||||||
|
expect(fmtMs(215)).toBe('3:35');
|
||||||
|
});
|
||||||
|
it('handles minutes >= 10 correctly', () => {
|
||||||
|
expect(fmtMs(605)).toBe('10:05');
|
||||||
|
});
|
||||||
|
it('clamps negative input to 0:00', () => {
|
||||||
|
expect(fmtMs(-3)).toBe('0:00');
|
||||||
|
});
|
||||||
|
it('floors fractional seconds', () => {
|
||||||
|
expect(fmtMs(7.9)).toBe('0:07');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtRelative', () => {
|
||||||
|
const now = new Date('2026-04-30T12:00:00Z');
|
||||||
|
it('formats <60s as Xs', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-30T11:59:30Z'), now)).toBe('30s');
|
||||||
|
});
|
||||||
|
it('formats <1h as Xm', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-30T11:55:00Z'), now)).toBe('5m');
|
||||||
|
});
|
||||||
|
it('formats <24h as Xh', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-30T09:00:00Z'), now)).toBe('3h');
|
||||||
|
});
|
||||||
|
it('formats >=24h as Xd', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-28T12:00:00Z'), now)).toBe('2d');
|
||||||
|
});
|
||||||
|
it('returns "now" for the current moment', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-30T12:00:00Z'), now)).toBe('now');
|
||||||
|
});
|
||||||
|
it('returns "now" for a future timestamp (clock skew)', () => {
|
||||||
|
expect(fmtRelative(new Date('2026-04-30T12:00:05Z'), now)).toBe('now');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtJpDate', () => {
|
||||||
|
it('formats with full kanji', () => {
|
||||||
|
// build the date in local time so the test isn't tz-sensitive
|
||||||
|
const d = new Date(2026, 3, 30); // month is 0-indexed → April
|
||||||
|
expect(fmtJpDate(d)).toBe('2026年4月30日');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtJpTime', () => {
|
||||||
|
it('formats HH:MM:SS', () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(7); d.setMinutes(3); d.setSeconds(9);
|
||||||
|
expect(fmtJpTime(d)).toBe('07:03:09');
|
||||||
|
});
|
||||||
|
});
|
||||||
56
frontend/src/tests/stations.test.ts
Normal file
56
frontend/src/tests/stations.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { listStations, readMeta } from '@lib/stations';
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURES = path.resolve(HERE, 'fixtures/library');
|
||||||
|
|
||||||
|
describe('readMeta', () => {
|
||||||
|
it('returns null for missing _meta.yml', async () => {
|
||||||
|
expect(await readMeta(FIXTURES, 'empty')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for malformed yaml (logged, not thrown)', async () => {
|
||||||
|
expect(await readMeta(FIXTURES, 'bad-yaml')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the id has uppercase letters (regex rejection, no fs read)', async () => {
|
||||||
|
expect(await readMeta(FIXTURES, 'NotLower')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid station', async () => {
|
||||||
|
const s = await readMeta(FIXTURES, 'alpha');
|
||||||
|
expect(s).toEqual({
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
description: 'alpha station',
|
||||||
|
color: '#ff0000',
|
||||||
|
tags: ['test', 'alpha'],
|
||||||
|
mounts: { mp3: '/alpha.mp3', opus: '/alpha.opus' },
|
||||||
|
cover: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets cover when a cover file exists', async () => {
|
||||||
|
const s = await readMeta(FIXTURES, 'beta');
|
||||||
|
expect(s?.cover).toBe('/api/stations/beta/cover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listStations', () => {
|
||||||
|
it('returns only valid stations, sorted by id', async () => {
|
||||||
|
const ss = await listStations(FIXTURES);
|
||||||
|
expect(ss.map((s) => s.id)).toEqual(['alpha', 'beta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty and bad-yaml folders', async () => {
|
||||||
|
const ss = await listStations(FIXTURES);
|
||||||
|
expect(ss.find((s) => s.id === 'empty')).toBeUndefined();
|
||||||
|
expect(ss.find((s) => s.id === 'bad-yaml')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a non-existent root gracefully', async () => {
|
||||||
|
expect(await listStations('/no/such/dir')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
frontend/tsconfig.json
Normal file
15
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": ["src/**/*", "astro.config.mjs"],
|
||||||
|
"exclude": ["dist", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@lib/*": ["src/lib/*"],
|
||||||
|
"@components/*": ["src/components/*"],
|
||||||
|
"@styles/*": ["src/styles/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@lib': resolve(__dirname, 'src/lib'),
|
||||||
|
'@components': resolve(__dirname, 'src/components'),
|
||||||
|
'@styles': resolve(__dirname, 'src/styles'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
17
frontend/vitest.config.ts
Normal file
17
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
include: ['src/tests/**/*.test.ts', 'src/tests/**/*.test.tsx'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@lib': resolve(__dirname, 'src/lib'),
|
||||||
|
'@components': resolve(__dirname, 'src/components'),
|
||||||
|
'@styles': resolve(__dirname, 'src/styles'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
45
scripts/restore-album-covers.sh
Executable file
45
scripts/restore-album-covers.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# one-time: extract cover.jpg from each album archive in
|
||||||
|
# ~/Downloads/Telegram\ Desktop and upload to summer storage box.
|
||||||
|
# safe to re-run; skips albums whose cover already exists on summer.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="${SRC:-/c/Users/user/Downloads/Telegram Desktop}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/keys/devilreef}"
|
||||||
|
SSH_OPTS=(-o IdentityAgent=none -o IdentitiesOnly=yes -i "$SSH_KEY")
|
||||||
|
REMOTE_LIB="/mnt/trashbox/denpa-radio/library/minecraft/tracks"
|
||||||
|
|
||||||
|
declare -A MAP=(
|
||||||
|
["C418 - Minecraft - Volume Alpha.tar.gz"]="Volume Alpha"
|
||||||
|
["C418 - Minecraft - Volume Beta.tar.gz"]="Volume Beta"
|
||||||
|
["Lena_Raine,_Minecraft_Minecraft_Caves_Cliffs_Original_Game.gz"]="Caves & Cliffs"
|
||||||
|
["Minecraft - Minecraft_ Pixel Drift.tar.gz"]="Pixel Drift"
|
||||||
|
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Original_Game_Soundt.gz"]="Minecraft Dungeons"
|
||||||
|
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Creeping_Winter_Ori.gz"]="Minecraft Dungeons - Creeping Winter"
|
||||||
|
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Echoing_Void_Origin.gz"]="Minecraft Dungeons - Echoing Void"
|
||||||
|
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Howling_Peaks_Origi.gz"]="Minecraft Dungeons - Howling Peaks"
|
||||||
|
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Jungle_Awakens_Orig.gz"]="Minecraft Dungeons - Jungle Awakens"
|
||||||
|
["Peter_Hont,_Samuel_berg,_Minecraft_Minecraft_Dungeons_Ultima.gz"]="Minecraft Dungeons - Ultimate Additions"
|
||||||
|
)
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
for archive in "${!MAP[@]}"; do
|
||||||
|
album="${MAP[$archive]}"
|
||||||
|
remote_path="$REMOTE_LIB/$album/cover.jpg"
|
||||||
|
|
||||||
|
if ssh "${SSH_OPTS[@]}" summer "test -f \"$remote_path\""; then
|
||||||
|
echo "skip: $album (cover exists)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "extract: $archive → $album"
|
||||||
|
tar -xzf "$SRC/$archive" -C "$tmp" cover.jpg
|
||||||
|
echo "upload: $album"
|
||||||
|
ssh "${SSH_OPTS[@]}" summer "mkdir -p \"$REMOTE_LIB/$album\" && cat > \"$remote_path\"" < "$tmp/cover.jpg"
|
||||||
|
rm "$tmp/cover.jpg"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done"
|
||||||
|
ssh "${SSH_OPTS[@]}" summer "ls \"$REMOTE_LIB\"/*/cover.jpg"
|
||||||
5
streamer/.dockerignore
Normal file
5
streamer/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
tests
|
||||||
|
*.test.ts
|
||||||
|
.vitest
|
||||||
29
streamer/Dockerfile
Normal file
29
streamer/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
chromium \
|
||||||
|
ffmpeg \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
ca-certificates \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
4344
streamer/package-lock.json
generated
Normal file
4344
streamer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
streamer/package.json
Normal file
30
streamer/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "denpa-streamer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:views": "node --experimental-strip-types src/views/build.ts",
|
||||||
|
"build:server": "tsc -p tsconfig.json",
|
||||||
|
"build": "npm run build:views && npm run build:server",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint src tests"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer-core": "^23.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
streamer/src/chrome.ts
Normal file
60
streamer/src/chrome.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import puppeteer, { Browser, CDPSession, Page } from "puppeteer-core";
|
||||||
|
|
||||||
|
export interface ChromeOpts {
|
||||||
|
pageUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
framerate: number;
|
||||||
|
jpegQuality?: number; // 0..100, default 85
|
||||||
|
executablePath?: string; // default /usr/bin/chromium
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChromeRenderer extends EventEmitter {
|
||||||
|
private browser: Browser | null = null;
|
||||||
|
private page: Page | null = null;
|
||||||
|
private cdp: CDPSession | null = null;
|
||||||
|
|
||||||
|
constructor(private opts: ChromeOpts) { super(); }
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.browser = await puppeteer.launch({
|
||||||
|
executablePath: this.opts.executablePath ?? "/usr/bin/chromium",
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
`--window-size=${this.opts.width},${this.opts.height}`,
|
||||||
|
"--autoplay-policy=no-user-gesture-required",
|
||||||
|
],
|
||||||
|
defaultViewport: { width: this.opts.width, height: this.opts.height },
|
||||||
|
});
|
||||||
|
this.page = await this.browser.newPage();
|
||||||
|
await this.page.goto(this.opts.pageUrl, { waitUntil: "networkidle2" });
|
||||||
|
|
||||||
|
this.cdp = await this.page.target().createCDPSession();
|
||||||
|
await this.cdp.send("Page.startScreencast", {
|
||||||
|
format: "jpeg",
|
||||||
|
quality: this.opts.jpegQuality ?? 85,
|
||||||
|
everyNthFrame: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cdp.on("Page.screencastFrame", async (frame) => {
|
||||||
|
const buf = Buffer.from(frame.data, "base64");
|
||||||
|
this.emit("frame", buf);
|
||||||
|
await this.cdp!.send("Page.screencastFrameAck", { sessionId: frame.sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.browser.on("disconnected", () => this.emit("disconnected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
try { await this.cdp?.send("Page.stopScreencast"); } catch { /* ignore */ }
|
||||||
|
await this.browser?.close().catch(() => {});
|
||||||
|
this.browser = null;
|
||||||
|
this.page = null;
|
||||||
|
this.cdp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
streamer/src/config.ts
Normal file
86
streamer/src/config.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
export class ConfigError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConfigError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
station: string;
|
||||||
|
rtmpUrl: string;
|
||||||
|
pcmHost: string;
|
||||||
|
pcmPort: number;
|
||||||
|
nowPlayingDir: string;
|
||||||
|
libraryDir: string;
|
||||||
|
style: "denpa" | "modern";
|
||||||
|
tz: string;
|
||||||
|
videoBitrate: string;
|
||||||
|
audioBitrate: string;
|
||||||
|
framerate: number;
|
||||||
|
resolution: { width: number; height: number };
|
||||||
|
stationDisplayName?: string;
|
||||||
|
stationTagline?: string;
|
||||||
|
stationTuneInUrl: string;
|
||||||
|
branding: boolean;
|
||||||
|
healthPort: number;
|
||||||
|
logLevel: "debug" | "info" | "warn" | "error";
|
||||||
|
icecastStatusUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_(env: Record<string, string | undefined>, key: string): string {
|
||||||
|
const v = env[key];
|
||||||
|
if (!v) throw new ConfigError(`missing required env ${key}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePcm(url: string): { host: string; port: number } {
|
||||||
|
const m = url.match(/^tcp:\/\/([^:/]+):(\d+)$/);
|
||||||
|
if (!m) throw new ConfigError(`invalid LIQUIDSOAP_PCM (expected tcp://host:port): ${url}`);
|
||||||
|
return { host: m[1]!, port: Number(m[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRes(s: string): { width: number; height: number } {
|
||||||
|
const m = s.match(/^(\d+)x(\d+)$/);
|
||||||
|
if (!m) throw new ConfigError(`invalid RESOLUTION: ${s}`);
|
||||||
|
return { width: Number(m[1]), height: Number(m[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||||
|
const style = require_(env, "STYLE");
|
||||||
|
if (style !== "denpa" && style !== "modern") {
|
||||||
|
throw new ConfigError(`STYLE must be 'denpa' or 'modern', got ${style}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtmpUrl = require_(env, "RTMP_URL");
|
||||||
|
if (!/^rtmps?:\/\//.test(rtmpUrl)) {
|
||||||
|
throw new ConfigError(`RTMP_URL must start with rtmp:// or rtmps://`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host: pcmHost, port: pcmPort } = parsePcm(require_(env, "LIQUIDSOAP_PCM"));
|
||||||
|
const logLevel = (env.LOG_LEVEL ?? "info") as Config["logLevel"];
|
||||||
|
if (!["debug", "info", "warn", "error"].includes(logLevel)) {
|
||||||
|
throw new ConfigError(`invalid LOG_LEVEL: ${logLevel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
station: require_(env, "STATION"),
|
||||||
|
rtmpUrl,
|
||||||
|
pcmHost,
|
||||||
|
pcmPort,
|
||||||
|
nowPlayingDir: env.NOW_PLAYING_DIR ?? "/now-playing",
|
||||||
|
libraryDir: env.LIBRARY_DIR ?? "/library",
|
||||||
|
style,
|
||||||
|
tz: env.TZ ?? "UTC",
|
||||||
|
videoBitrate: env.VIDEO_BITRATE ?? "4500k",
|
||||||
|
audioBitrate: env.AUDIO_BITRATE ?? "160k",
|
||||||
|
framerate: Number(env.FRAMERATE ?? 30),
|
||||||
|
resolution: parseRes(env.RESOLUTION ?? "1920x1080"),
|
||||||
|
stationDisplayName: env.STATION_DISPLAY_NAME || undefined,
|
||||||
|
stationTagline: env.STATION_TAGLINE || undefined,
|
||||||
|
stationTuneInUrl: env.STATION_TUNE_IN_URL ?? "denpa.femboy.page",
|
||||||
|
branding: (env.BRANDING ?? "true").toLowerCase() !== "false",
|
||||||
|
healthPort: Number(env.HEALTH_PORT ?? 12010),
|
||||||
|
logLevel,
|
||||||
|
icecastStatusUrl: env.ICECAST_STATUS_URL || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
streamer/src/ffmpeg.ts
Normal file
69
streamer/src/ffmpeg.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
|
||||||
|
export interface FfmpegOpts {
|
||||||
|
rtmpUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
framerate: number;
|
||||||
|
videoBitrate: string;
|
||||||
|
audioBitrate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ffmpeg extends EventEmitter {
|
||||||
|
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||||
|
|
||||||
|
constructor(private opts: FfmpegOpts) { super(); }
|
||||||
|
|
||||||
|
start(): { videoIn: Writable; audioIn: Writable } {
|
||||||
|
const args = [
|
||||||
|
"-loglevel", "warning",
|
||||||
|
// video in (mjpeg pipe on fd 3) — declare full-range to silence swscaler
|
||||||
|
"-color_range", "jpeg",
|
||||||
|
"-f", "image2pipe", "-c:v", "mjpeg",
|
||||||
|
"-thread_queue_size", "1024",
|
||||||
|
"-r", String(this.opts.framerate), "-i", "pipe:3",
|
||||||
|
// audio in (s16le pcm pipe on fd 4)
|
||||||
|
"-f", "s16le", "-ar", "48000", "-ac", "2",
|
||||||
|
"-thread_queue_size", "1024",
|
||||||
|
"-i", "pipe:4",
|
||||||
|
// video encode (explicit colorspace conversion silences swscaler nags)
|
||||||
|
"-vf", "scale=in_range=full:out_range=tv,format=yuv420p",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast",
|
||||||
|
"-color_range", "tv",
|
||||||
|
"-b:v", this.opts.videoBitrate,
|
||||||
|
"-maxrate", this.opts.videoBitrate, "-bufsize", "9000k",
|
||||||
|
"-g", String(this.opts.framerate * 2), "-keyint_min", String(this.opts.framerate * 2),
|
||||||
|
"-r", String(this.opts.framerate),
|
||||||
|
// audio encode
|
||||||
|
"-c:a", "aac", "-b:a", this.opts.audioBitrate, "-ar", "48000",
|
||||||
|
// output
|
||||||
|
"-f", "flv", this.opts.rtmpUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.proc = spawn("ffmpeg", args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
|
||||||
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
|
|
||||||
|
const stdio = (this.proc as unknown as { stdio: Writable[] }).stdio;
|
||||||
|
const videoIn = stdio[3]!;
|
||||||
|
const audioIn = stdio[4]!;
|
||||||
|
|
||||||
|
this.proc.stderr.on("data", (d) => this.emit("log", d.toString()));
|
||||||
|
this.proc.on("exit", (code) => this.emit("exit", code));
|
||||||
|
|
||||||
|
// ffmpeg exiting (e.g. RTMP drop) closes its stdins — subsequent writes
|
||||||
|
// emit EPIPE on these sockets, which would crash the supervisor as
|
||||||
|
// unhandled. Catch them; the proc.on("exit") handler does the actual restart.
|
||||||
|
videoIn.on("error", (err) => this.emit("log", `videoIn: ${err.message}`));
|
||||||
|
audioIn.on("error", (err) => this.emit("log", `audioIn: ${err.message}`));
|
||||||
|
|
||||||
|
return { videoIn, audioIn };
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.proc?.kill("SIGTERM");
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
streamer/src/fft.ts
Normal file
122
streamer/src/fft.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
export interface SpectrumOpts {
|
||||||
|
bars: number;
|
||||||
|
sampleRate: number;
|
||||||
|
fftSize?: number;
|
||||||
|
smoothing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpectrumAnalyzer {
|
||||||
|
private fftSize: number;
|
||||||
|
private window: Float32Array;
|
||||||
|
private buffer: Float32Array;
|
||||||
|
private bufFill = 0;
|
||||||
|
private smooth: Float32Array;
|
||||||
|
private bandStarts: number[];
|
||||||
|
private bandEnds: number[];
|
||||||
|
|
||||||
|
constructor(private opts: SpectrumOpts) {
|
||||||
|
this.fftSize = opts.fftSize ?? 1024;
|
||||||
|
this.window = new Float32Array(this.fftSize);
|
||||||
|
for (let i = 0; i < this.fftSize; i++) {
|
||||||
|
this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.fftSize - 1));
|
||||||
|
}
|
||||||
|
this.buffer = new Float32Array(this.fftSize);
|
||||||
|
this.smooth = new Float32Array(opts.bars);
|
||||||
|
[this.bandStarts, this.bandEnds] = this.makeBands();
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeBands(): [number[], number[]] {
|
||||||
|
const minHz = 40;
|
||||||
|
const maxHz = Math.min(16000, this.opts.sampleRate / 2);
|
||||||
|
const fftBin = (hz: number) => Math.round((hz / this.opts.sampleRate) * this.fftSize);
|
||||||
|
const starts: number[] = [];
|
||||||
|
const ends: number[] = [];
|
||||||
|
for (let i = 0; i < this.opts.bars; i++) {
|
||||||
|
const a = minHz * Math.pow(maxHz / minHz, i / this.opts.bars);
|
||||||
|
const b = minHz * Math.pow(maxHz / minHz, (i + 1) / this.opts.bars);
|
||||||
|
starts.push(Math.max(1, fftBin(a)));
|
||||||
|
ends.push(Math.max(starts[i]! + 1, fftBin(b)));
|
||||||
|
}
|
||||||
|
return [starts, ends];
|
||||||
|
}
|
||||||
|
|
||||||
|
feed(pcm: Buffer): void {
|
||||||
|
const samples = pcm.length / 4;
|
||||||
|
let bi = this.bufFill;
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const l = pcm.readInt16LE(i * 4);
|
||||||
|
const r = pcm.readInt16LE(i * 4 + 2);
|
||||||
|
this.buffer[bi] = ((l + r) / 2) / 32768;
|
||||||
|
bi++;
|
||||||
|
if (bi >= this.fftSize) {
|
||||||
|
this.computeFrame();
|
||||||
|
bi = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bufFill = bi;
|
||||||
|
}
|
||||||
|
|
||||||
|
bars(): number[] {
|
||||||
|
const out = new Array<number>(this.opts.bars);
|
||||||
|
for (let i = 0; i < this.opts.bars; i++) out[i] = this.smooth[i]!;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeFrame(): void {
|
||||||
|
const re = new Float32Array(this.fftSize);
|
||||||
|
const im = new Float32Array(this.fftSize);
|
||||||
|
for (let i = 0; i < this.fftSize; i++) re[i] = this.buffer[i]! * this.window[i]!;
|
||||||
|
fftInPlace(re, im);
|
||||||
|
|
||||||
|
const alpha = this.opts.smoothing ?? 0.6;
|
||||||
|
// db scaling — same idea as web audio's getByteFrequencyData.
|
||||||
|
// hann-windowed full-scale sine has peak mag ~ fftSize/2; reference to that.
|
||||||
|
const ref = this.fftSize / 2;
|
||||||
|
const dbMin = -80;
|
||||||
|
const dbMax = -20;
|
||||||
|
for (let i = 0; i < this.opts.bars; i++) {
|
||||||
|
let mag = 0;
|
||||||
|
const s = this.bandStarts[i]!;
|
||||||
|
const e = this.bandEnds[i]!;
|
||||||
|
for (let k = s; k < e; k++) mag += Math.sqrt(re[k]! * re[k]! + im[k]! * im[k]!);
|
||||||
|
mag /= e - s;
|
||||||
|
const db = 20 * Math.log10(Math.max(mag, 1e-9) / ref);
|
||||||
|
const norm = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin)));
|
||||||
|
this.smooth[i] = alpha * this.smooth[i]! + (1 - alpha) * norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fftInPlace(re: Float32Array, im: Float32Array): void {
|
||||||
|
const n = re.length;
|
||||||
|
for (let i = 1, j = 0; i < n; i++) {
|
||||||
|
let bit = n >> 1;
|
||||||
|
for (; j & bit; bit >>= 1) j ^= bit;
|
||||||
|
j ^= bit;
|
||||||
|
if (i < j) {
|
||||||
|
[re[i], re[j]] = [re[j]!, re[i]!];
|
||||||
|
[im[i], im[j]] = [im[j]!, im[i]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let len = 2; len <= n; len <<= 1) {
|
||||||
|
const halfLen = len >> 1;
|
||||||
|
const angle = (-2 * Math.PI) / len;
|
||||||
|
const wre = Math.cos(angle);
|
||||||
|
const wim = Math.sin(angle);
|
||||||
|
for (let i = 0; i < n; i += len) {
|
||||||
|
let cre = 1;
|
||||||
|
let cim = 0;
|
||||||
|
for (let k = 0; k < halfLen; k++) {
|
||||||
|
const tre = cre * re[i + k + halfLen]! - cim * im[i + k + halfLen]!;
|
||||||
|
const tim = cre * im[i + k + halfLen]! + cim * re[i + k + halfLen]!;
|
||||||
|
re[i + k + halfLen] = re[i + k]! - tre;
|
||||||
|
im[i + k + halfLen] = im[i + k]! - tim;
|
||||||
|
re[i + k] = re[i + k]! + tre;
|
||||||
|
im[i + k] = im[i + k]! + tim;
|
||||||
|
const ncre = cre * wre - cim * wim;
|
||||||
|
cim = cre * wim + cim * wre;
|
||||||
|
cre = ncre;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
streamer/src/icecast.ts
Normal file
43
streamer/src/icecast.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
export interface IcecastOpts {
|
||||||
|
statusUrl: string;
|
||||||
|
mountName: string;
|
||||||
|
intervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcecastListeners { current: number; peak: number; }
|
||||||
|
|
||||||
|
export class IcecastPoller extends EventEmitter {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private peak = 0;
|
||||||
|
|
||||||
|
constructor(private opts: IcecastOpts) { super(); }
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.poll();
|
||||||
|
this.timer = setInterval(() => this.poll(), this.opts.intervalMs ?? 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(this.opts.statusUrl, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!r.ok) throw new Error(`icecast status ${r.status}`);
|
||||||
|
const j = await r.json() as {
|
||||||
|
icestats: { source?: { listenurl: string; listeners: number }[] | { listenurl: string; listeners: number } };
|
||||||
|
};
|
||||||
|
const sources = j.icestats.source;
|
||||||
|
const list = Array.isArray(sources) ? sources : sources ? [sources] : [];
|
||||||
|
const found = list.find((s) => s.listenurl.endsWith(this.opts.mountName));
|
||||||
|
const current = found?.listeners ?? 0;
|
||||||
|
this.peak = Math.max(this.peak, current);
|
||||||
|
this.emit("listeners", { current, peak: this.peak });
|
||||||
|
} catch (err) {
|
||||||
|
this.emit("warn", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
streamer/src/index.ts
Normal file
133
streamer/src/index.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { NowPlayingWatcher } from "./nowplaying.js";
|
||||||
|
import { PageServer } from "./page-server.js";
|
||||||
|
import { PcmTap } from "./pcm-tap.js";
|
||||||
|
import { SpectrumAnalyzer } from "./fft.js";
|
||||||
|
import { ChromeRenderer } from "./chrome.js";
|
||||||
|
import { Ffmpeg } from "./ffmpeg.js";
|
||||||
|
import { IcecastPoller } from "./icecast.js";
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const log = (level: string, msg: string, extra?: unknown) =>
|
||||||
|
console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(extra ? { extra } : {}) }));
|
||||||
|
|
||||||
|
const PAGE_PORT = 8080;
|
||||||
|
const STATIC_DIR = join(import.meta.dirname ?? __dirname, "views");
|
||||||
|
const BARS = cfg.style === "denpa" ? 48 : 72;
|
||||||
|
|
||||||
|
let lastFrameAt = 0;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const np = new NowPlayingWatcher({
|
||||||
|
station: cfg.station,
|
||||||
|
nowPlayingDir: cfg.nowPlayingDir,
|
||||||
|
libraryDir: cfg.libraryDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = new PageServer({
|
||||||
|
port: PAGE_PORT,
|
||||||
|
staticDir: STATIC_DIR,
|
||||||
|
libraryDir: cfg.libraryDir,
|
||||||
|
style: cfg.style,
|
||||||
|
station: cfg.station,
|
||||||
|
tz: cfg.tz,
|
||||||
|
tuneInUrl: cfg.stationTuneInUrl,
|
||||||
|
branding: cfg.branding,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pcm = new PcmTap({ host: cfg.pcmHost, port: cfg.pcmPort });
|
||||||
|
const spec = new SpectrumAnalyzer({ bars: BARS, sampleRate: 48000 });
|
||||||
|
|
||||||
|
const ice = cfg.icecastStatusUrl
|
||||||
|
? new IcecastPoller({ statusUrl: cfg.icecastStatusUrl, mountName: `/${cfg.station}.mp3` })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ffmpeg = new Ffmpeg({
|
||||||
|
rtmpUrl: cfg.rtmpUrl,
|
||||||
|
width: cfg.resolution.width,
|
||||||
|
height: cfg.resolution.height,
|
||||||
|
framerate: cfg.framerate,
|
||||||
|
videoBitrate: cfg.videoBitrate,
|
||||||
|
audioBitrate: cfg.audioBitrate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chrome = new ChromeRenderer({
|
||||||
|
pageUrl: `http://127.0.0.1:${PAGE_PORT}/?style=${cfg.style}`,
|
||||||
|
width: cfg.resolution.width,
|
||||||
|
height: cfg.resolution.height,
|
||||||
|
framerate: cfg.framerate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.start();
|
||||||
|
await np.start();
|
||||||
|
np.on("change", (s) => page.pushNowPlaying(s));
|
||||||
|
if (np.current()) page.pushNowPlaying(np.current());
|
||||||
|
|
||||||
|
const pushTimer: NodeJS.Timeout = setInterval(() => page.pushSpectrum(spec.bars()), Math.round(1000 / 30));
|
||||||
|
|
||||||
|
ice?.on("listeners", (l) => page.pushListeners(l));
|
||||||
|
ice?.start();
|
||||||
|
|
||||||
|
const { videoIn, audioIn } = ffmpeg.start();
|
||||||
|
ffmpeg.on("log", (m: string) => {
|
||||||
|
const msg = m.trim();
|
||||||
|
// mjpeg decoder emits an unsuppressable swscaler "deprecated pixel format"
|
||||||
|
// nag before any filters run; cosmetic, drop it from logs.
|
||||||
|
if (msg.includes("deprecated pixel format used")) return;
|
||||||
|
if (/^\[swscaler @ 0x[0-9a-f]+\]\s*$/.test(msg)) return;
|
||||||
|
log("info", "ffmpeg", msg);
|
||||||
|
});
|
||||||
|
ffmpeg.on("exit", (code: number | null) => {
|
||||||
|
log("error", "ffmpeg exited", { code });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
pcm.on("data", (chunk: Buffer) => {
|
||||||
|
spec.feed(chunk);
|
||||||
|
audioIn.write(chunk);
|
||||||
|
});
|
||||||
|
pcm.on("connecting", () => log("info", "pcm connecting"));
|
||||||
|
pcm.on("connected", () => log("info", "pcm connected"));
|
||||||
|
pcm.on("disconnected", () => log("warn", "pcm disconnected"));
|
||||||
|
pcm.start();
|
||||||
|
|
||||||
|
await chrome.start();
|
||||||
|
chrome.on("frame", (buf: Buffer) => {
|
||||||
|
lastFrameAt = Date.now();
|
||||||
|
videoIn.write(buf);
|
||||||
|
});
|
||||||
|
chrome.on("disconnected", () => {
|
||||||
|
log("error", "chrome disconnected");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// health endpoint
|
||||||
|
const health = createServer((req, res) => {
|
||||||
|
if (req.url !== "/health") { res.statusCode = 404; res.end(); return; }
|
||||||
|
const stale = Date.now() - lastFrameAt > 5000;
|
||||||
|
res.statusCode = stale ? 503 : 200;
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify({ ok: !stale, lastFrameAt }));
|
||||||
|
});
|
||||||
|
health.listen(cfg.healthPort, "0.0.0.0", () => log("info", `health :${cfg.healthPort}`));
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
clearInterval(pushTimer);
|
||||||
|
pcm.stop();
|
||||||
|
await chrome.stop();
|
||||||
|
ffmpeg.stop();
|
||||||
|
np.stop();
|
||||||
|
ice?.stop();
|
||||||
|
await page.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
log("error", "fatal", { err: err instanceof Error ? err.message : String(err) });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
100
streamer/src/nowplaying.ts
Normal file
100
streamer/src/nowplaying.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { existsSync, readFileSync, watch, FSWatcher } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export interface NowPlayingTrack {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: string;
|
||||||
|
cover_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NowPlayingState {
|
||||||
|
station: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
filename: string;
|
||||||
|
duration: string;
|
||||||
|
started_at: string;
|
||||||
|
cover_path: string;
|
||||||
|
cover_url: string;
|
||||||
|
up_next: (NowPlayingTrack & { cover_url: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NowPlayingOpts {
|
||||||
|
station: string;
|
||||||
|
nowPlayingDir: string;
|
||||||
|
libraryDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NowPlayingWatcher extends EventEmitter {
|
||||||
|
private state: NowPlayingState | null = null;
|
||||||
|
private watcher: FSWatcher | null = null;
|
||||||
|
private debounce: NodeJS.Timeout | null = null;
|
||||||
|
private path: string;
|
||||||
|
|
||||||
|
constructor(private opts: NowPlayingOpts) {
|
||||||
|
super();
|
||||||
|
this.path = join(opts.nowPlayingDir, `${opts.station}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.read();
|
||||||
|
this.watcher = watch(this.opts.nowPlayingDir, (_event, filename) => {
|
||||||
|
if (filename !== `${this.opts.station}.json`) return;
|
||||||
|
if (this.debounce) clearTimeout(this.debounce);
|
||||||
|
this.debounce = setTimeout(() => this.read(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.watcher?.close();
|
||||||
|
this.watcher = null;
|
||||||
|
if (this.debounce) clearTimeout(this.debounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
current(): NowPlayingState | null {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(): void {
|
||||||
|
if (!existsSync(this.path)) return;
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.path, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const cover_path = this.resolveCover(parsed.cover_path, parsed.filename);
|
||||||
|
const up_next = (parsed.up_next ?? []).map((t: NowPlayingTrack) => ({
|
||||||
|
...t,
|
||||||
|
cover_url: this.coverUrl(this.resolveCover(t.cover_path, "")),
|
||||||
|
}));
|
||||||
|
this.state = {
|
||||||
|
...parsed,
|
||||||
|
cover_path,
|
||||||
|
cover_url: this.coverUrl(cover_path),
|
||||||
|
up_next,
|
||||||
|
};
|
||||||
|
this.emit("change", this.state);
|
||||||
|
} catch (err) {
|
||||||
|
this.emit("warn", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCover(suggested: string, _filename: string): string {
|
||||||
|
if (suggested && existsSync(this.toAbsolute(suggested))) return suggested;
|
||||||
|
const stationCover = `/library/${this.opts.station}/cover.jpg`;
|
||||||
|
if (existsSync(this.toAbsolute(stationCover))) return stationCover;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAbsolute(libraryPath: string): string {
|
||||||
|
if (!libraryPath.startsWith("/library/")) return libraryPath;
|
||||||
|
return join(this.opts.libraryDir, libraryPath.slice("/library/".length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private coverUrl(libraryPath: string): string {
|
||||||
|
if (!libraryPath) return "";
|
||||||
|
return `/cover?path=${encodeURIComponent(libraryPath)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
streamer/src/page-server.ts
Normal file
129
streamer/src/page-server.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { createReadStream, existsSync } from "node:fs";
|
||||||
|
import { extname, join } from "node:path";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
export interface PageServerOpts {
|
||||||
|
port: number;
|
||||||
|
staticDir: string;
|
||||||
|
libraryDir: string;
|
||||||
|
style: "denpa" | "modern";
|
||||||
|
station: string;
|
||||||
|
tz: string;
|
||||||
|
tuneInUrl: string;
|
||||||
|
branding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PageServer extends EventEmitter {
|
||||||
|
private nowClients = new Set<ServerResponse>();
|
||||||
|
private specClients = new Set<ServerResponse>();
|
||||||
|
private latestNowJson: string | null = null;
|
||||||
|
private latestListeners: { current: number; peak: number } = { current: 0, peak: 0 };
|
||||||
|
private server = createServer((req, res) => this.handle(req, res));
|
||||||
|
|
||||||
|
constructor(private opts: PageServerOpts) { super(); }
|
||||||
|
|
||||||
|
start(): Promise<void> {
|
||||||
|
return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): Promise<void> {
|
||||||
|
for (const c of this.nowClients) c.end();
|
||||||
|
for (const c of this.specClients) c.end();
|
||||||
|
return new Promise((resolve) => this.server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pushNowPlaying(json: unknown): void {
|
||||||
|
this.latestNowJson = JSON.stringify(json);
|
||||||
|
const payload = `data: ${this.latestNowJson}\n\n`;
|
||||||
|
for (const c of this.nowClients) c.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSpectrum(bars: number[]): void {
|
||||||
|
const payload = `data: ${JSON.stringify(bars)}\n\n`;
|
||||||
|
for (const c of this.specClients) c.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushListeners(l: { current: number; peak: number }): void {
|
||||||
|
this.latestListeners = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(req: IncomingMessage, res: ServerResponse): void {
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
|
||||||
|
if (url.pathname === "/") return this.serveIndex(res);
|
||||||
|
if (url.pathname.startsWith("/assets/")) return this.serveStatic(url.pathname, res);
|
||||||
|
if (url.pathname === "/now-playing") return this.serveSse(res, this.nowClients, this.latestNowJson);
|
||||||
|
if (url.pathname === "/spectrum") return this.serveSse(res, this.specClients, null);
|
||||||
|
if (url.pathname === "/listeners") {
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify(this.latestListeners));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/cover") return this.serveCover(url, res);
|
||||||
|
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private serveIndex(res: ServerResponse): void {
|
||||||
|
const indexPath = join(this.opts.staticDir, "index.html");
|
||||||
|
if (!existsSync(indexPath)) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end("index not built");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inject = `<script>window.__STREAMER_CONFIG__=${JSON.stringify({
|
||||||
|
style: this.opts.style,
|
||||||
|
station: this.opts.station,
|
||||||
|
tz: this.opts.tz,
|
||||||
|
tuneInUrl: this.opts.tuneInUrl,
|
||||||
|
branding: this.opts.branding,
|
||||||
|
})}</script>`;
|
||||||
|
let html = "";
|
||||||
|
createReadStream(indexPath, "utf8")
|
||||||
|
.on("data", (chunk) => (html += chunk))
|
||||||
|
.on("end", () => {
|
||||||
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
||||||
|
res.end(html.replace("</head>", `${inject}</head>`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private serveStatic(pathname: string, res: ServerResponse): void {
|
||||||
|
const file = join(this.opts.staticDir, pathname.replace(/^\/assets\//, ""));
|
||||||
|
if (!file.startsWith(this.opts.staticDir) || !existsSync(file)) {
|
||||||
|
res.statusCode = 404; res.end("not found"); return;
|
||||||
|
}
|
||||||
|
const ct = ({
|
||||||
|
".js": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
} as const)[extname(file) as ".js" | ".css" | ".woff2"] ?? "application/octet-stream";
|
||||||
|
res.setHeader("content-type", ct);
|
||||||
|
res.setHeader("cache-control", "max-age=86400");
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serveSse(res: ServerResponse, set: Set<ServerResponse>, initial: string | null): void {
|
||||||
|
res.setHeader("content-type", "text/event-stream");
|
||||||
|
res.setHeader("cache-control", "no-cache");
|
||||||
|
res.setHeader("connection", "keep-alive");
|
||||||
|
res.flushHeaders();
|
||||||
|
set.add(res);
|
||||||
|
if (initial) res.write(`data: ${initial}\n\n`);
|
||||||
|
res.on("close", () => set.delete(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
private serveCover(url: URL, res: ServerResponse): void {
|
||||||
|
const requested = url.searchParams.get("path") ?? "";
|
||||||
|
if (!requested.startsWith("/library/")) { res.statusCode = 400; res.end(); return; }
|
||||||
|
const file = join(this.opts.libraryDir, requested.slice("/library/".length));
|
||||||
|
if (!file.startsWith(this.opts.libraryDir) || !existsSync(file)) {
|
||||||
|
res.statusCode = 404; res.end(); return;
|
||||||
|
}
|
||||||
|
const ext = extname(file).toLowerCase();
|
||||||
|
res.setHeader("content-type", ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg");
|
||||||
|
res.setHeader("cache-control", "no-store");
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
streamer/src/pcm-tap.ts
Normal file
51
streamer/src/pcm-tap.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { Socket, connect } from "node:net";
|
||||||
|
|
||||||
|
export interface PcmTapOpts {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
reconnectInitialMs?: number;
|
||||||
|
reconnectMaxMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PcmTap extends EventEmitter {
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
private backoff: number;
|
||||||
|
|
||||||
|
constructor(private opts: PcmTapOpts) {
|
||||||
|
super();
|
||||||
|
this.backoff = opts.reconnectInitialMs ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
this.socket?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): void {
|
||||||
|
this.emit("connecting");
|
||||||
|
const sock = connect({ host: this.opts.host, port: this.opts.port });
|
||||||
|
this.socket = sock;
|
||||||
|
|
||||||
|
sock.on("connect", () => {
|
||||||
|
this.emit("connected");
|
||||||
|
this.backoff = this.opts.reconnectInitialMs ?? 1000;
|
||||||
|
});
|
||||||
|
sock.on("data", (chunk) => this.emit("data", chunk));
|
||||||
|
sock.on("error", (err) => this.emit("warn", err));
|
||||||
|
sock.on("close", () => {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.emit("disconnected");
|
||||||
|
const max = this.opts.reconnectMaxMs ?? 30000;
|
||||||
|
const wait = Math.min(this.backoff, max);
|
||||||
|
this.backoff = Math.min(this.backoff * 2, max);
|
||||||
|
setTimeout(() => this.connect(), wait);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
streamer/src/views/App.tsx
Normal file
41
streamer/src/views/App.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { StrictMode, useEffect, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { StreamDenpa } from "./denpa.tsx";
|
||||||
|
import { StreamModern } from "./modern.tsx";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__STREAMER_CONFIG__: { style: "denpa" | "modern"; station: string; tz: string; tuneInUrl: string; branding: boolean };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useListeners(): { current: number; peak: number } {
|
||||||
|
const [v, setV] = useState({ current: 0, peak: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
let peak = 0;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/listeners`);
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
peak = Math.max(peak, j.current ?? 0);
|
||||||
|
setV({ current: j.current ?? 0, peak });
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const t = setInterval(tick, 30000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const cfg = window.__STREAMER_CONFIG__;
|
||||||
|
const listeners = useListeners();
|
||||||
|
return cfg.style === "denpa"
|
||||||
|
? <StreamDenpa cfg={cfg} listeners={listeners} />
|
||||||
|
: <StreamModern cfg={cfg} listeners={listeners} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||||
19
streamer/src/views/build.ts
Normal file
19
streamer/src/views/build.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { copyFileSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const outDir = "dist/views";
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
copyFileSync("src/views/index.html", `${outDir}/index.html`);
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ["src/views/App.tsx"],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
target: "es2022",
|
||||||
|
jsx: "automatic",
|
||||||
|
outfile: `${outDir}/main.js`,
|
||||||
|
loader: { ".tsx": "tsx", ".ts": "ts" },
|
||||||
|
define: { "process.env.NODE_ENV": '"production"' },
|
||||||
|
minify: true,
|
||||||
|
});
|
||||||
|
console.log("views bundled →", outDir);
|
||||||
265
streamer/src/views/denpa.tsx
Normal file
265
streamer/src/views/denpa.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNowPlaying } from "./shared/useNowPlaying.ts";
|
||||||
|
import { useSpectrum } from "./shared/useSpectrum.ts";
|
||||||
|
import { useElapsed } from "./shared/useElapsed.ts";
|
||||||
|
import { fmtMs, fmtClock, fmtClockSec, fmtJpDate } from "./shared/format.ts";
|
||||||
|
|
||||||
|
const KAOMOJI_STREAM = ["(´。• ᵕ •。`)", "٩(◕‿◕)۶", "(。◕‿◕。)", "(>ω<)", "(✿◠‿◠)"];
|
||||||
|
|
||||||
|
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
|
||||||
|
interface Listeners { current: number; peak: number; }
|
||||||
|
interface Props { cfg: Cfg; listeners: Listeners; }
|
||||||
|
|
||||||
|
export function StreamDenpa({ cfg, listeners }: Props) {
|
||||||
|
const np = useNowPlaying();
|
||||||
|
const spectrum = useSpectrum();
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startedAt = np ? Number(np.started_at) : 0;
|
||||||
|
const duration = np ? Number(np.duration) : 0;
|
||||||
|
const elapsed = useElapsed(startedAt, duration);
|
||||||
|
|
||||||
|
if (!np) return <div style={{ width: 1920, height: 1080, background: "#1a0820" }} />;
|
||||||
|
|
||||||
|
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
|
||||||
|
const upNext = np.up_next.slice(0, 4);
|
||||||
|
|
||||||
|
const nameJp = "ラジオ";
|
||||||
|
const channel = "CH.01";
|
||||||
|
const bitrate = 192;
|
||||||
|
const codec = "MP3/OPUS";
|
||||||
|
const genre = "auto";
|
||||||
|
const uptime = "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sd-root">
|
||||||
|
<style>{`
|
||||||
|
.sd-root {
|
||||||
|
width: 1920px; height: 1080px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 25% 0%, #5ef7ff22 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 100%, #ff3ea522 0%, transparent 55%),
|
||||||
|
#1a0820;
|
||||||
|
color: #fff4e8;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
padding: 56px 64px 0;
|
||||||
|
font-variant-emoji: text;
|
||||||
|
}
|
||||||
|
.sd-root::before {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.07; mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
.sd-root::after {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.sd-header { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 36px; margin-bottom: 36px; position: relative; z-index: 2; }
|
||||||
|
.sd-wm .eyebrow { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 6px; color: #ff3ea5; }
|
||||||
|
.sd-wm .big {
|
||||||
|
display: block; font-family: var(--f-pixel); font-size: 110px; line-height: 0.9;
|
||||||
|
margin-top: 10px; padding-right: 0.12em;
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, #fff4e8 0%, #fff4e8 50%, #ff3ea5 50%, #ff3ea5 100%);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
filter: drop-shadow(0 0 14px #ff3ea580);
|
||||||
|
}
|
||||||
|
.sd-channel { font-family: var(--f-hand); font-size: 34px; transform: rotate(-2deg); line-height: 1.05; max-width: 280px; align-self: flex-end; padding-bottom: 14px; }
|
||||||
|
.sd-status { text-align: right; font-family: var(--f-mono); font-size: 18px; line-height: 1.5; color: #5ef7ff; padding-bottom: 12px; }
|
||||||
|
.sd-status .live { color: #ff3ea5; font-family: var(--f-pixel); font-size: 22px; letter-spacing: 3px; }
|
||||||
|
.sd-status .blink { animation: sd-blink 1s steps(2) infinite; }
|
||||||
|
@keyframes sd-blink { 50% { opacity: 0.15; } }
|
||||||
|
.sd-body { display: grid; grid-template-columns: 1.3fr 1fr; gap: 48px; position: relative; z-index: 2; }
|
||||||
|
.sd-deck {
|
||||||
|
background: linear-gradient(180deg, #fff4e8 0%, #f5e0c4 100%);
|
||||||
|
color: #0a0410; padding: 32px;
|
||||||
|
border: 4px solid #0a0410;
|
||||||
|
box-shadow: 12px 12px 0 #ff3ea5, 24px 24px 0 #5ef7ff;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sd-deck::before { content: ""; position: absolute; inset: 6px; border: 1.5px dashed #0a041044; pointer-events: none; }
|
||||||
|
.sd-screen { background: #0a0410; color: #5eff9b; font-family: var(--f-mono); font-size: 18px; padding: 22px 26px; border: 3px inset #0a0410; position: relative; overflow: hidden; }
|
||||||
|
.sd-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px); }
|
||||||
|
.sd-screen-row { display: flex; justify-content: space-between; opacity: 0.78; }
|
||||||
|
.sd-screen .now { font-family: var(--f-pixel); font-size: 56px; line-height: 1.05; color: #fff4e8; text-shadow: 0 0 12px #5eff9b88; margin: 10px 0 6px; }
|
||||||
|
.sd-screen .artist { font-size: 24px; color: #5ef7ff; margin-bottom: 14px; }
|
||||||
|
.sd-progress-row { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||||
|
.sd-progress { flex: 1; height: 6px; background: #5eff9b22; border: 1px solid #5eff9b66; }
|
||||||
|
.sd-progress-fill { height: 100%; background: #5eff9b; transition: width 200ms linear; }
|
||||||
|
.sd-time { font-size: 18px; color: #5ef7ff; min-width: 56px; }
|
||||||
|
.sd-reels { display: flex; align-items: center; gap: 22px; padding: 22px; margin-top: 26px; background: #0a0410; border: 3px solid #0a0410; }
|
||||||
|
.sd-reel {
|
||||||
|
width: 150px; height: 150px; border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, #fff4e8 0 22px, transparent 22px),
|
||||||
|
conic-gradient(from 0deg, #2a1030 0 25%, #1a0820 25% 50%, #2a1030 50% 75%, #1a0820 75%);
|
||||||
|
border: 3px solid #fff4e8;
|
||||||
|
animation: sd-spin 1.6s linear infinite;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sd-reel::before { content: ""; position: absolute; inset: 10px; border-radius: 50%; border: 1px dashed #fff4e833; }
|
||||||
|
@keyframes sd-spin { to { transform: rotate(360deg); } }
|
||||||
|
.sd-strip { flex: 1; height: 10px; background: linear-gradient(90deg, #fff4e8 0%, #d4a578 50%, #fff4e8 100%); border-top: 2px solid #0a0410; border-bottom: 2px solid #0a0410; }
|
||||||
|
.sd-spectrum { display: flex; align-items: flex-end; gap: 3px; height: 110px; margin-top: 22px; padding: 12px; background: #0a0410; border: 3px solid #0a0410; }
|
||||||
|
.sd-spec-bar { flex: 1; min-height: 4px; background: linear-gradient(180deg, #ff3ea5 0%, #ffe24a 50%, #5eff9b 100%); transition: height 80ms linear; }
|
||||||
|
.sd-right { display: flex; flex-direction: column; gap: 28px; min-width: 0; }
|
||||||
|
.sd-card { background: #1a0820; border: 2px solid #0a0410; padding: 24px 28px; position: relative; }
|
||||||
|
.sd-card::before { content: ""; position: absolute; inset: 5px; border: 1px dashed #ff3ea533; pointer-events: none; }
|
||||||
|
.sd-h { font-family: var(--f-pixel); font-size: 18px; letter-spacing: 4px; color: #ffe24a; margin-bottom: 4px; }
|
||||||
|
.sd-h-en { font-family: var(--f-pixel); font-size: 24px; color: #fff4e8; letter-spacing: 1px; }
|
||||||
|
.sd-h-sub { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; margin-top: 6px; }
|
||||||
|
.sd-queue { margin-top: 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sd-queue-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; padding: 8px 4px; border-bottom: 1px dashed #ff3ea522; align-items: baseline; }
|
||||||
|
.sd-queue-row:last-child { border-bottom: none; }
|
||||||
|
.sd-queue-row .n { font-family: var(--f-mono); font-size: 14px; color: #5ef7ff; }
|
||||||
|
.sd-queue-row .t { font-family: var(--f-pixel); font-size: 22px; color: #fff4e8; }
|
||||||
|
.sd-queue-row .a { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; }
|
||||||
|
.sd-queue-row .d { font-family: var(--f-mono); font-size: 14px; color: #ff3ea5; align-self: center; }
|
||||||
|
.sd-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 14px; }
|
||||||
|
.sd-stat { padding: 14px 16px; background: #0a0410; border: 1.5px dashed #5ef7ff44; }
|
||||||
|
.sd-stat .lbl { font-family: var(--f-pixel); font-size: 12px; letter-spacing: 2px; color: #ffe24a; }
|
||||||
|
.sd-stat .val { font-family: var(--f-pixel); font-size: 32px; color: #fff4e8; margin-top: 4px; line-height: 1; }
|
||||||
|
.sd-stat .sub { font-family: var(--f-mono); font-size: 12px; color: #5ef7ff; margin-top: 4px; }
|
||||||
|
.sd-bomb { position: absolute; pointer-events: none; z-index: 5; }
|
||||||
|
.sd-bomb.b1 { top: 36px; right: 340px; }
|
||||||
|
.sd-bomb.b2 { top: 250px; right: 36px; }
|
||||||
|
.sd-bottom {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0;
|
||||||
|
height: 64px; background: #0a0410;
|
||||||
|
border-top: 2px dashed #ff3ea566;
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center; gap: 24px; padding: 0 36px;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
.sd-listen { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 2px; color: #5ef7ff; }
|
||||||
|
.sd-listen b { color: #ff3ea5; margin-right: 10px; }
|
||||||
|
.sd-ticker { overflow: hidden; white-space: nowrap; }
|
||||||
|
.sd-ticker-inner { display: inline-block; padding-left: 100%; animation: sd-marq 36s linear infinite; color: #ffe24a; font-family: var(--f-mono); font-size: 16px; letter-spacing: 1px; }
|
||||||
|
@keyframes sd-marq { to { transform: translateX(-100%); } }
|
||||||
|
.sd-clock { font-family: var(--f-pixel); font-size: 26px; color: #fff4e8; letter-spacing: 2px; padding: 6px 14px; background: #1a0820; border: 1.5px dashed #5ef7ff66; }
|
||||||
|
.sd-sticker { display: inline-block; padding: 10px 18px; font-family: var(--f-pixel); font-size: 20px; letter-spacing: 1px; border: 2px solid #0a0410; box-shadow: 4px 4px 0 #0a0410; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="sd-header">
|
||||||
|
{cfg.branding && (
|
||||||
|
<>
|
||||||
|
<div className="sd-wm">
|
||||||
|
<div className="eyebrow">電波 TRANSMISSION</div>
|
||||||
|
<span className="big">denpa.fm</span>
|
||||||
|
</div>
|
||||||
|
<div className="sd-channel">
|
||||||
|
{channel} ←<br/>
|
||||||
|
{nameJp}<br/>
|
||||||
|
{"<3"} block radio
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="sd-status">
|
||||||
|
<div className="live">[<span className="blink">REC</span>] ON AIR</div>
|
||||||
|
<div>{fmtJpDate(now)}</div>
|
||||||
|
<div>{fmtClockSec(now)} JST</div>
|
||||||
|
<div>{listeners.current} listening · peak {listeners.peak}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sd-body">
|
||||||
|
<div className="sd-deck">
|
||||||
|
<div className="sd-screen">
|
||||||
|
<div className="sd-screen-row">
|
||||||
|
<span>>> NOW PLAYING</span>
|
||||||
|
<span>{bitrate}kbps · {codec}</span>
|
||||||
|
</div>
|
||||||
|
<div className="now">{np.title}</div>
|
||||||
|
<div className="artist">{np.artist} — {np.album}</div>
|
||||||
|
<div className="sd-progress-row">
|
||||||
|
<span className="sd-time">{fmtMs(elapsed)}</span>
|
||||||
|
<div className="sd-progress"><div className="sd-progress-fill" style={{ width: pct + "%" }}></div></div>
|
||||||
|
<span className="sd-time">{fmtMs(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-reels">
|
||||||
|
<div className="sd-reel"></div>
|
||||||
|
<div className="sd-strip"></div>
|
||||||
|
<div className="sd-reel"></div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-spectrum">
|
||||||
|
{spectrum.map((v, i) => (
|
||||||
|
<div key={i} className="sd-spec-bar" style={{ height: `${Math.max(4, v * 100)}%` }}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sd-right">
|
||||||
|
<div className="sd-card">
|
||||||
|
<div className="sd-h">次の曲</div>
|
||||||
|
<div className="sd-h-en">// up next</div>
|
||||||
|
<div className="sd-h-sub">auto-DJ · no ads · no skips · forever</div>
|
||||||
|
<div className="sd-queue">
|
||||||
|
{upNext.map((q, i) => (
|
||||||
|
<div key={i} className="sd-queue-row">
|
||||||
|
<span className="n">0{i + 1}</span>
|
||||||
|
<span>
|
||||||
|
<span className="t">{q.title}</span><br/>
|
||||||
|
<span className="a">{q.artist}</span>
|
||||||
|
</span>
|
||||||
|
<span className="d">{fmtMs(Number(q.duration))}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sd-card">
|
||||||
|
<div className="sd-h">放送局情報</div>
|
||||||
|
<div className="sd-h-en">// station info</div>
|
||||||
|
<div className="sd-stats">
|
||||||
|
<div className="sd-stat">
|
||||||
|
<div className="lbl">CHANNEL</div>
|
||||||
|
<div className="val">{channel}</div>
|
||||||
|
<div className="sub">{genre}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-stat">
|
||||||
|
<div className="lbl">UPTIME</div>
|
||||||
|
<div className="val">{uptime}</div>
|
||||||
|
<div className="sub">since 2024</div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-stat">
|
||||||
|
<div className="lbl">LISTENING</div>
|
||||||
|
<div className="val">{listeners.current}</div>
|
||||||
|
<div className="sub">peak {listeners.peak} today</div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-stat">
|
||||||
|
<div className="lbl">BITRATE</div>
|
||||||
|
<div className="val">{bitrate}k</div>
|
||||||
|
<div className="sub">{codec}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticker bombs */}
|
||||||
|
<div className="sd-bomb b1"><span className="sd-sticker" style={{ background: "#5ef7ff", color: "#0a0410", transform: "rotate(-6deg)" }}>// 24/7 ON AIR //</span></div>
|
||||||
|
<div className="sd-bomb b2"><span className="sd-sticker" style={{ background: "#ffe24a", color: "#0a0410", transform: "rotate(5deg)" }}>SIDE A · CH.01</span></div>
|
||||||
|
|
||||||
|
{/* Bottom strip */}
|
||||||
|
{cfg.branding && (
|
||||||
|
<div className="sd-bottom">
|
||||||
|
<div className="sd-listen"><b>>></b> tune in @ {cfg.tuneInUrl}</div>
|
||||||
|
<div className="sd-ticker">
|
||||||
|
<div className="sd-ticker-inner">
|
||||||
|
※ now broadcasting from a small room in tokyo ※ 24/7 always-on {cfg.station} station ※ stream URL: {cfg.tuneInUrl} ※ requests via @denpa_bot ※ no ads · no algorithms · just blocks ※ {KAOMOJI_STREAM.join(" ※ ")} ※
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sd-clock">{fmtClock(now)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
streamer/src/views/index.html
Normal file
23
streamer/src/views/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>denpa streamer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DotGothic16&family=VT323&family=Reenie+Beanie&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--f-pixel: "DotGothic16", "VT323", monospace;
|
||||||
|
--f-mono: "VT323", ui-monospace, monospace;
|
||||||
|
--f-hand: "Reenie Beanie", cursive;
|
||||||
|
}
|
||||||
|
html, body { margin: 0; padding: 0; background: #0a0410; }
|
||||||
|
body { font-family: "Inter", system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/assets/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
268
streamer/src/views/modern.tsx
Normal file
268
streamer/src/views/modern.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNowPlaying } from "./shared/useNowPlaying.ts";
|
||||||
|
import { useSpectrum } from "./shared/useSpectrum.ts";
|
||||||
|
import { useElapsed } from "./shared/useElapsed.ts";
|
||||||
|
import { fmtMs, fmtClock } from "./shared/format.ts";
|
||||||
|
|
||||||
|
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
|
||||||
|
interface Listeners { current: number; peak: number; }
|
||||||
|
interface Props { cfg: Cfg; listeners: Listeners; }
|
||||||
|
|
||||||
|
export function StreamModern({ cfg, listeners }: Props) {
|
||||||
|
const np = useNowPlaying();
|
||||||
|
const spectrum = useSpectrum();
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startedAt = np ? Number(np.started_at) : 0;
|
||||||
|
const duration = np ? Number(np.duration) : 0;
|
||||||
|
const elapsed = useElapsed(startedAt, duration);
|
||||||
|
|
||||||
|
if (!np) return <div style={{ width: 1920, height: 1080, background: "#0c0d10" }} />;
|
||||||
|
|
||||||
|
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
|
||||||
|
const upNext = np.up_next.slice(0, 3);
|
||||||
|
const bitrate = 192;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sm-root">
|
||||||
|
<style>{`
|
||||||
|
.sm-root {
|
||||||
|
width: 1920px; height: 1080px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #0c0d10;
|
||||||
|
color: #f5f5f0;
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
padding: 80px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 720px 1fr;
|
||||||
|
gap: 80px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
/* Ambient color wash from artwork */
|
||||||
|
.sm-bg {
|
||||||
|
position: absolute; inset: -20%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 25% 40%, #4a6b7a 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 75% 60%, #6a8a5a 0%, transparent 55%);
|
||||||
|
filter: blur(80px); opacity: 0.5; z-index: 0;
|
||||||
|
}
|
||||||
|
.sm-grain {
|
||||||
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.04; mix-blend-mode: overlay; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand mark — top-left */
|
||||||
|
.sm-brand {
|
||||||
|
position: absolute; top: 48px; left: 80px;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 4px;
|
||||||
|
color: #f5f5f0aa; z-index: 3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.sm-brand-dot { width: 10px; height: 10px; border-radius: 50%; background: #e8546b; box-shadow: 0 0 12px #e8546b; }
|
||||||
|
|
||||||
|
/* Live + clock — top-right */
|
||||||
|
.sm-meta-top {
|
||||||
|
position: absolute; top: 48px; right: 80px;
|
||||||
|
display: flex; align-items: center; gap: 28px;
|
||||||
|
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 3px;
|
||||||
|
color: #f5f5f0aa; z-index: 3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.sm-live { display: flex; align-items: center; gap: 10px; color: #e8546b; }
|
||||||
|
.sm-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #e8546b; animation: sm-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes sm-pulse { 50% { opacity: 0.2; transform: scale(0.8); } }
|
||||||
|
.sm-clock { font-variant-numeric: tabular-nums; color: #f5f5f0; font-weight: 500; letter-spacing: 2px; }
|
||||||
|
|
||||||
|
/* Artwork */
|
||||||
|
.sm-art-wrap { position: relative; z-index: 2; aspect-ratio: 1 / 1; }
|
||||||
|
.sm-art {
|
||||||
|
width: 100%; aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, #4a6b7a 0%, #2a3a48 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 60px 120px rgba(0,0,0,0.6),
|
||||||
|
0 0 0 1px rgba(255,255,255,0.04);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* Stylized cover for "Sweden" — soft hills + sun */
|
||||||
|
.sm-art svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* Right column */
|
||||||
|
.sm-right { position: relative; z-index: 2; max-width: 880px; }
|
||||||
|
.sm-eyebrow {
|
||||||
|
font-family: "Inter", sans-serif; font-size: 13px; letter-spacing: 4px;
|
||||||
|
text-transform: uppercase; color: #f5f5f0aa; margin-bottom: 20px;
|
||||||
|
display: flex; gap: 18px; align-items: center;
|
||||||
|
}
|
||||||
|
.sm-eyebrow .pill {
|
||||||
|
padding: 4px 10px; border: 1px solid #f5f5f033; border-radius: 999px;
|
||||||
|
font-size: 11px; letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.sm-title {
|
||||||
|
font-family: "Inter", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 124px; line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: #f5f5f0;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.sm-artist {
|
||||||
|
font-family: "Inter", sans-serif; font-weight: 500;
|
||||||
|
font-size: 36px; color: #d4d0c4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.sm-album {
|
||||||
|
font-family: "Inter", sans-serif; font-weight: 400;
|
||||||
|
font-size: 22px; color: #d4d0c499;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.sm-progress {
|
||||||
|
height: 4px; background: #f5f5f01a;
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.sm-progress-fill {
|
||||||
|
height: 100%; background: #f5f5f0;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 200ms linear;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.sm-times {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
font-family: "Inter", sans-serif; font-size: 16px;
|
||||||
|
color: #f5f5f0aa; font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spectrum (subtle, bottom of right column) */
|
||||||
|
.sm-spectrum {
|
||||||
|
display: flex; align-items: flex-end; gap: 3px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.sm-spec-bar {
|
||||||
|
flex: 1; min-height: 2px;
|
||||||
|
background: #f5f5f088;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: height 80ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Up next — minimal */
|
||||||
|
.sm-upnext {
|
||||||
|
border-top: 1px solid #f5f5f01a;
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
.sm-upnext-h {
|
||||||
|
font-family: "Inter", sans-serif; font-size: 12px;
|
||||||
|
letter-spacing: 4px; text-transform: uppercase;
|
||||||
|
color: #f5f5f088;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sm-upnext-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.sm-upnext-row {
|
||||||
|
display: grid; grid-template-columns: 1fr auto;
|
||||||
|
gap: 24px; align-items: baseline;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #f5f5f00f;
|
||||||
|
}
|
||||||
|
.sm-upnext-row:last-child { border-bottom: none; }
|
||||||
|
.sm-upnext-row .t {
|
||||||
|
font-family: "Inter", sans-serif; font-size: 22px; font-weight: 500;
|
||||||
|
color: #f5f5f0;
|
||||||
|
}
|
||||||
|
.sm-upnext-row .a { font-size: 16px; color: #d4d0c499; margin-left: 14px; font-weight: 400; }
|
||||||
|
.sm-upnext-row .d { font-size: 16px; color: #f5f5f088; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Bottom strip */
|
||||||
|
.sm-bottom {
|
||||||
|
position: absolute; left: 80px; right: 80px; bottom: 56px;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-family: "Inter", sans-serif; font-size: 13px;
|
||||||
|
letter-spacing: 3px; text-transform: uppercase;
|
||||||
|
color: #f5f5f088;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.sm-bottom .listeners { color: #f5f5f0; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="sm-bg"></div>
|
||||||
|
<div className="sm-grain"></div>
|
||||||
|
|
||||||
|
{cfg.branding && (
|
||||||
|
<div className="sm-brand">
|
||||||
|
<span className="sm-brand-dot"></span>
|
||||||
|
denpa.fm — {cfg.station}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sm-meta-top">
|
||||||
|
<div className="sm-live"><span className="sm-live-dot"></span>Live</div>
|
||||||
|
<div className="sm-clock">{fmtClock(now)} JST</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artwork (left) */}
|
||||||
|
<div className="sm-art-wrap">
|
||||||
|
<img className="sm-art" src={np.cover_url} alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="sm-right">
|
||||||
|
<div className="sm-eyebrow">
|
||||||
|
<span>Now playing</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="sm-title">{np.title}</h1>
|
||||||
|
<div className="sm-artist">{np.artist}</div>
|
||||||
|
<div className="sm-album">{np.album} · {bitrate}kbps</div>
|
||||||
|
|
||||||
|
<div className="sm-progress"><div className="sm-progress-fill" style={{ width: pct + "%" }}></div></div>
|
||||||
|
<div className="sm-times">
|
||||||
|
<span>{fmtMs(elapsed)}</span>
|
||||||
|
<span>{fmtMs(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm-spectrum">
|
||||||
|
{spectrum.map((v, i) => (
|
||||||
|
<div key={i} className="sm-spec-bar" style={{ height: `${Math.max(3, v * 100)}%`, opacity: 0.3 + v * 0.6 }}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm-upnext">
|
||||||
|
<div className="sm-upnext-h">Up next</div>
|
||||||
|
<div className="sm-upnext-list">
|
||||||
|
{upNext.map((q, i) => (
|
||||||
|
<div key={i} className="sm-upnext-row">
|
||||||
|
<div>
|
||||||
|
<span className="t">{q.title}</span>
|
||||||
|
<span className="a">{q.artist}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d">{fmtMs(Number(q.duration))}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.branding && (
|
||||||
|
<div className="sm-bottom">
|
||||||
|
<span>Tune in at <span className="listeners">{cfg.tuneInUrl}</span></span>
|
||||||
|
<span><span className="listeners">{listeners.current}</span> listeners · 24/7 · auto-DJ</span>
|
||||||
|
<span>{cfg.tuneInUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
streamer/src/views/shared/format.ts
Normal file
14
streamer/src/views/shared/format.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const fmtMs = (sec: number): string => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtClock = (d: Date): string =>
|
||||||
|
`${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
|
export const fmtClockSec = (d: Date): string =>
|
||||||
|
`${fmtClock(d)}:${d.getSeconds().toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
|
export const fmtJpDate = (d: Date): string =>
|
||||||
|
`${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
10
streamer/src/views/shared/useElapsed.ts
Normal file
10
streamer/src/views/shared/useElapsed.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useElapsed(startedAt: number, duration: number): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now() / 1000);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(Date.now() / 1000), 200);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
return Math.min(duration, Math.max(0, now - startedAt));
|
||||||
|
}
|
||||||
30
streamer/src/views/shared/useNowPlaying.ts
Normal file
30
streamer/src/views/shared/useNowPlaying.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface NowPlayingTrack {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: string;
|
||||||
|
cover_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NowPlayingState {
|
||||||
|
station: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: string;
|
||||||
|
started_at: string;
|
||||||
|
cover_url: string;
|
||||||
|
up_next: NowPlayingTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNowPlaying(): NowPlayingState | null {
|
||||||
|
const [state, setState] = useState<NowPlayingState | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource("/now-playing");
|
||||||
|
es.onmessage = (e) => setState(JSON.parse(e.data));
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
11
streamer/src/views/shared/useSpectrum.ts
Normal file
11
streamer/src/views/shared/useSpectrum.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useSpectrum(): number[] {
|
||||||
|
const [bars, setBars] = useState<number[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource("/spectrum");
|
||||||
|
es.onmessage = (e) => setBars(JSON.parse(e.data));
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
48
streamer/tests/config.test.ts
Normal file
48
streamer/tests/config.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadConfig, ConfigError } from "../src/config.ts";
|
||||||
|
|
||||||
|
const baseEnv = {
|
||||||
|
STATION: "minecraft",
|
||||||
|
RTMP_URL: "rtmp://example/live/key",
|
||||||
|
LIQUIDSOAP_PCM: "tcp://liquidsoap:9100",
|
||||||
|
NOW_PLAYING_DIR: "/now-playing",
|
||||||
|
LIBRARY_DIR: "/library",
|
||||||
|
STYLE: "denpa",
|
||||||
|
TZ: "Asia/Tokyo",
|
||||||
|
VIDEO_BITRATE: "4500k",
|
||||||
|
AUDIO_BITRATE: "160k",
|
||||||
|
FRAMERATE: "30",
|
||||||
|
RESOLUTION: "1920x1080",
|
||||||
|
STATION_TUNE_IN_URL: "denpa.femboy.page",
|
||||||
|
HEALTH_PORT: "12010",
|
||||||
|
LOG_LEVEL: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("loadConfig", () => {
|
||||||
|
it("parses required fields", () => {
|
||||||
|
const cfg = loadConfig(baseEnv);
|
||||||
|
expect(cfg.station).toBe("minecraft");
|
||||||
|
expect(cfg.rtmpUrl).toBe("rtmp://example/live/key");
|
||||||
|
expect(cfg.style).toBe("denpa");
|
||||||
|
expect(cfg.framerate).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid style", () => {
|
||||||
|
expect(() => loadConfig({ ...baseEnv, STYLE: "weird" })).toThrow(ConfigError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed rtmp url", () => {
|
||||||
|
expect(() => loadConfig({ ...baseEnv, RTMP_URL: "not-a-url" })).toThrow(ConfigError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing required field", () => {
|
||||||
|
const { STATION: _omit, ...rest } = baseEnv;
|
||||||
|
expect(() => loadConfig(rest)).toThrow(ConfigError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses pcm tcp url", () => {
|
||||||
|
const cfg = loadConfig(baseEnv);
|
||||||
|
expect(cfg.pcmHost).toBe("liquidsoap");
|
||||||
|
expect(cfg.pcmPort).toBe(9100);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
streamer/tests/fft.test.ts
Normal file
42
streamer/tests/fft.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SpectrumAnalyzer } from "../src/fft.ts";
|
||||||
|
|
||||||
|
function sineFrame(freqHz: number, sampleRate = 48000, samples = 1024): Buffer {
|
||||||
|
const buf = Buffer.alloc(samples * 2 * 2); // s16le stereo
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const v = Math.round(Math.sin((2 * Math.PI * freqHz * i) / sampleRate) * 16000);
|
||||||
|
buf.writeInt16LE(v, i * 4);
|
||||||
|
buf.writeInt16LE(v, i * 4 + 2);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SpectrumAnalyzer", () => {
|
||||||
|
it("produces N bars of normalized [0..1] floats", () => {
|
||||||
|
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
|
||||||
|
a.feed(sineFrame(1000));
|
||||||
|
const bars = a.bars();
|
||||||
|
expect(bars).toHaveLength(48);
|
||||||
|
bars.forEach((v) => {
|
||||||
|
expect(v).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 200hz sine puts most energy in low bars (logarithmic binning)", () => {
|
||||||
|
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
|
||||||
|
for (let n = 0; n < 4; n++) a.feed(sineFrame(200));
|
||||||
|
const bars = a.bars();
|
||||||
|
const lowSum = bars.slice(0, 16).reduce((s, v) => s + v, 0);
|
||||||
|
const highSum = bars.slice(32).reduce((s, v) => s + v, 0);
|
||||||
|
expect(lowSum).toBeGreaterThan(0.1);
|
||||||
|
expect(lowSum).toBeGreaterThan(highSum + 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silence produces all-near-zero bars", () => {
|
||||||
|
const a = new SpectrumAnalyzer({ bars: 72, sampleRate: 48000 });
|
||||||
|
a.feed(Buffer.alloc(1024 * 4));
|
||||||
|
const bars = a.bars();
|
||||||
|
bars.forEach((v) => expect(v).toBeLessThan(0.01));
|
||||||
|
});
|
||||||
|
});
|
||||||
3
streamer/tests/fixtures/library/minecraft/_meta.yml
vendored
Normal file
3
streamer/tests/fixtures/library/minecraft/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: Minecraft
|
||||||
|
description: blocks
|
||||||
|
color: "#5ef7ff"
|
||||||
BIN
streamer/tests/fixtures/library/minecraft/cover.jpg
vendored
Normal file
BIN
streamer/tests/fixtures/library/minecraft/cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
BIN
streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg
vendored
Normal file
BIN
streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
14
streamer/tests/fixtures/now-playing/minecraft.json
vendored
Normal file
14
streamer/tests/fixtures/now-playing/minecraft.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"station": "minecraft",
|
||||||
|
"title": "Sweden",
|
||||||
|
"artist": "C418",
|
||||||
|
"album": "Volume Alpha",
|
||||||
|
"filename": "/library/minecraft/tracks/Volume Alpha/18. Sweden.flac",
|
||||||
|
"duration": "215",
|
||||||
|
"started_at": "1730358000",
|
||||||
|
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg",
|
||||||
|
"up_next": [
|
||||||
|
{ "title": "Wet Hands", "artist": "C418", "album": "Volume Alpha", "duration": "90",
|
||||||
|
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
103
streamer/tests/nowplaying.test.ts
Normal file
103
streamer/tests/nowplaying.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { NowPlayingWatcher } from "../src/nowplaying.ts";
|
||||||
|
|
||||||
|
let tmpRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpRoot = mkdtempSync(join(tmpdir(), "denpa-np-"));
|
||||||
|
cpSync("tests/fixtures/now-playing", join(tmpRoot, "now-playing"), { recursive: true });
|
||||||
|
cpSync("tests/fixtures/library", join(tmpRoot, "library"), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NowPlayingWatcher", () => {
|
||||||
|
it("reads initial state from disk", async () => {
|
||||||
|
const watcher = new NowPlayingWatcher({
|
||||||
|
station: "minecraft",
|
||||||
|
nowPlayingDir: join(tmpRoot, "now-playing"),
|
||||||
|
libraryDir: join(tmpRoot, "library"),
|
||||||
|
});
|
||||||
|
await watcher.start();
|
||||||
|
const state = watcher.current();
|
||||||
|
expect(state).not.toBeNull();
|
||||||
|
expect(state!.title).toBe("Sweden");
|
||||||
|
expect(state!.cover_url).toMatch(/^\/cover\?path=/);
|
||||||
|
expect(state!.up_next).toHaveLength(1);
|
||||||
|
watcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites cover_path to /cover?path=… url", async () => {
|
||||||
|
const watcher = new NowPlayingWatcher({
|
||||||
|
station: "minecraft",
|
||||||
|
nowPlayingDir: join(tmpRoot, "now-playing"),
|
||||||
|
libraryDir: join(tmpRoot, "library"),
|
||||||
|
});
|
||||||
|
await watcher.start();
|
||||||
|
expect(watcher.current()!.cover_url).toContain(
|
||||||
|
encodeURIComponent("/library/minecraft/tracks/Volume Alpha/cover.jpg"),
|
||||||
|
);
|
||||||
|
watcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits change events on file rewrite", async () => {
|
||||||
|
const watcher = new NowPlayingWatcher({
|
||||||
|
station: "minecraft",
|
||||||
|
nowPlayingDir: join(tmpRoot, "now-playing"),
|
||||||
|
libraryDir: join(tmpRoot, "library"),
|
||||||
|
});
|
||||||
|
await watcher.start();
|
||||||
|
|
||||||
|
let changes = 0;
|
||||||
|
watcher.on("change", () => changes++);
|
||||||
|
|
||||||
|
const newPayload = JSON.stringify({
|
||||||
|
station: "minecraft",
|
||||||
|
title: "Pigstep",
|
||||||
|
artist: "Lena Raine",
|
||||||
|
album: "Nether Update",
|
||||||
|
filename: "/library/minecraft/tracks/Nether/01. Pigstep.flac",
|
||||||
|
duration: "148",
|
||||||
|
started_at: "1730358500",
|
||||||
|
cover_path: "",
|
||||||
|
up_next: [],
|
||||||
|
});
|
||||||
|
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), newPayload);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(changes).toBeGreaterThan(0);
|
||||||
|
expect(watcher.current()!.title).toBe("Pigstep");
|
||||||
|
watcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to station cover when track cover missing", async () => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
station: "minecraft",
|
||||||
|
title: "Untagged Track",
|
||||||
|
artist: "?",
|
||||||
|
album: "?",
|
||||||
|
filename: "/library/minecraft/tracks/UnknownAlbum/track.flac",
|
||||||
|
duration: "120",
|
||||||
|
started_at: "1730358000",
|
||||||
|
cover_path: "",
|
||||||
|
up_next: [],
|
||||||
|
});
|
||||||
|
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), payload);
|
||||||
|
|
||||||
|
const watcher = new NowPlayingWatcher({
|
||||||
|
station: "minecraft",
|
||||||
|
nowPlayingDir: join(tmpRoot, "now-playing"),
|
||||||
|
libraryDir: join(tmpRoot, "library"),
|
||||||
|
});
|
||||||
|
await watcher.start();
|
||||||
|
expect(watcher.current()!.cover_url).toContain(
|
||||||
|
encodeURIComponent("/library/minecraft/cover.jpg"),
|
||||||
|
);
|
||||||
|
watcher.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
streamer/tsconfig.json
Normal file
19
streamer/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["src/views/**/*", "node_modules", "dist"]
|
||||||
|
}
|
||||||
8
streamer/vitest.config.ts
Normal file
8
streamer/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue