Compare commits
28 commits
52c6750a51
...
67beae5f6f
| 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 |
36 changed files with 6353 additions and 13 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ docs/superpowers/
|
||||||
|
|
||||||
# design handoff bundle from claude design — local reference only
|
# design handoff bundle from claude design — local reference only
|
||||||
.design-prototype/
|
.design-prototype/
|
||||||
|
.design-prototype-stream/
|
||||||
|
|
||||||
# generated configs and secrets
|
# generated configs and secrets
|
||||||
config/*.env
|
config/*.env
|
||||||
|
|
@ -15,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
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,145 @@ 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
|
||||||
# decoder-reported metadata may be empty; fall back to request.duration which
|
|
||||||
# reads it directly from the file. -1.0 means unknown.
|
|
||||||
meta_dur = m["duration"]
|
meta_dur = m["duration"]
|
||||||
dur_str =
|
dur_str =
|
||||||
if meta_dur != "" then
|
if meta_dur != "" then
|
||||||
meta_dur
|
meta_dur
|
||||||
else
|
else
|
||||||
# request.duration returns float? in liquidsoap 2.3 — unwrap with default -1.
|
|
||||||
d = null.get(default=-1., request.duration(filename))
|
d = null.get(default=-1., request.duration(filename))
|
||||||
if d > 0. then string(int_of_float(d)) else "" end
|
if d > 0. then string(int_of_float(d)) else "" end
|
||||||
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("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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -68,13 +184,10 @@ def append_history(station, m) =
|
||||||
end
|
end
|
||||||
|
|
||||||
# === station: minecraft ===
|
# === station: minecraft ===
|
||||||
minecraft = playlist(
|
minecraft_deck = make_deck("/library/minecraft/tracks")
|
||||||
reload_mode="watch",
|
minecraft = minecraft_deck.source
|
||||||
mode="randomize",
|
|
||||||
"/library/minecraft/tracks"
|
|
||||||
)
|
|
||||||
minecraft.on_track(fun (m) -> begin
|
minecraft.on_track(fun (m) -> begin
|
||||||
emit_now_playing("minecraft", m)
|
emit_now_playing("minecraft", m, minecraft_deck.peek(4))
|
||||||
append_history("minecraft", m)
|
append_history("minecraft", m)
|
||||||
end)
|
end)
|
||||||
minecraft = crossfade(minecraft)
|
minecraft = crossfade(minecraft)
|
||||||
|
|
@ -101,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
|
||||||
|
|
@ -68,3 +68,30 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
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
|
||||||
|
|
|
||||||
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