bbgfm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * [INPUT]: ink render/useInput/useApp, all components, usePlayer hook, channel data
3
+ * [OUTPUT]: App root component — layout + state machine + keyboard handler
4
+ * [POS]: Central orchestrator assembling Header, ChannelList, Player, StatusBar
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ interface Props {
8
+ initialChannel?: string;
9
+ }
10
+ export declare function App({ initialChannel }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * [INPUT]: ink render/useInput/useApp, all components, usePlayer hook, channel data
4
+ * [OUTPUT]: App root component — layout + state machine + keyboard handler
5
+ * [POS]: Central orchestrator assembling Header, ChannelList, Player, StatusBar
6
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
7
+ */
8
+ import { useState, useEffect, useRef } from 'react';
9
+ import { Box, useInput, useApp } from 'ink';
10
+ import { CHANNELS, DEFAULT_CHANNEL_ID } from './data/channels.js';
11
+ import { usePlayer } from './hooks/usePlayer.js';
12
+ import { Header } from './components/Header.js';
13
+ import { ChannelList } from './components/ChannelList.js';
14
+ import { Player } from './components/Player.js';
15
+ import { StatusBar } from './components/StatusBar.js';
16
+ export function App({ initialChannel }) {
17
+ const { exit } = useApp();
18
+ const player = usePlayer();
19
+ // Stable refs for player functions — useInput always gets the latest
20
+ const playerRef = useRef(player);
21
+ playerRef.current = player;
22
+ const defaultIndex = CHANNELS.findIndex(c => c.id === (initialChannel ?? DEFAULT_CHANNEL_ID));
23
+ const [selectedIndex, setSelectedIndex] = useState(Math.max(0, defaultIndex));
24
+ const selectedRef = useRef(selectedIndex);
25
+ selectedRef.current = selectedIndex;
26
+ // Auto-play default channel on mount
27
+ useEffect(() => {
28
+ const channel = CHANNELS[selectedRef.current];
29
+ if (channel)
30
+ playerRef.current.play(channel);
31
+ }, []);
32
+ // Helper: switch channel and auto-play
33
+ const switchAndPlay = (nextIndex) => {
34
+ setSelectedIndex(nextIndex);
35
+ selectedRef.current = nextIndex;
36
+ const channel = CHANNELS[nextIndex];
37
+ if (channel)
38
+ playerRef.current.play(channel);
39
+ };
40
+ // Keyboard bindings — all reads go through refs, zero stale closures
41
+ useInput((input, key) => {
42
+ const p = playerRef.current;
43
+ // Quit
44
+ if (input === 'q') {
45
+ p.stop().then(() => exit());
46
+ return;
47
+ }
48
+ // Navigate + auto-play
49
+ if (input === 'k' || key.upArrow) {
50
+ const cur = selectedRef.current;
51
+ switchAndPlay(cur > 0 ? cur - 1 : CHANNELS.length - 1);
52
+ return;
53
+ }
54
+ if (input === 'j' || key.downArrow) {
55
+ const cur = selectedRef.current;
56
+ switchAndPlay(cur < CHANNELS.length - 1 ? cur + 1 : 0);
57
+ return;
58
+ }
59
+ // Toggle play/stop
60
+ if (input === ' ') {
61
+ if (p.status === 'playing' || p.status === 'connecting') {
62
+ p.stop();
63
+ }
64
+ else {
65
+ const channel = CHANNELS[selectedRef.current];
66
+ if (channel)
67
+ p.play(channel);
68
+ }
69
+ return;
70
+ }
71
+ // Volume
72
+ if (input === '+' || input === '=') {
73
+ p.adjustVolume(5);
74
+ return;
75
+ }
76
+ if (input === '-') {
77
+ p.adjustVolume(-5);
78
+ return;
79
+ }
80
+ });
81
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Header, {}), _jsx(Player, { channel: player.channel, status: player.status, volume: player.volume, audioLevelRef: player.audioLevelRef, error: player.error }), _jsx(ChannelList, { channels: CHANNELS, selectedIndex: selectedIndex, activeChannelId: player.channel?.id ?? null }), _jsx(StatusBar, {})] }));
82
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * [INPUT]: ink Text/Box, Channel type from data/channels
3
+ * [OUTPUT]: ChannelList component — selectable channel items
4
+ * [POS]: Channel navigation panel, rendered by App
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ import type { Channel } from '../data/channels.js';
8
+ interface Props {
9
+ channels: Channel[];
10
+ selectedIndex: number;
11
+ activeChannelId: string | null;
12
+ }
13
+ export declare function ChannelList({ channels, selectedIndex, activeChannelId }: Props): import("react/jsx-runtime").JSX.Element;
14
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // ============================================================
4
+ // Component
5
+ // ============================================================
6
+ export function ChannelList({ channels, selectedIndex, activeChannelId }) {
7
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, underline: true, color: "white", children: " CHANNELS " }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: channels.map((ch, i) => {
8
+ const isSelected = i === selectedIndex;
9
+ const isActive = ch.id === activeChannelId;
10
+ const pointer = isSelected ? '▸' : ' ';
11
+ const indicator = isActive ? '♫' : ' ';
12
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isSelected ? 'green' : 'white', children: pointer }), _jsx(Text, { color: isActive ? 'green' : isSelected ? 'green' : 'gray', children: indicator }), _jsx(Text, { color: isSelected ? 'green' : 'white', bold: isSelected, children: ch.name }), _jsxs(Text, { dimColor: true, children: [" ", ch.tag] })] }, ch.id));
13
+ }) })] }));
14
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * [INPUT]: ink Text/Box
3
+ * [OUTPUT]: Header component — ASCII banner with version
4
+ * [POS]: Top-level decorative banner, rendered by App
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ export declare function Header(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // ============================================================
4
+ // ASCII Banner
5
+ // ============================================================
6
+ const BANNER = `
7
+ ██████╗ ██████╗ ██████╗ ███████╗███╗ ███╗
8
+ ██╔══██╗██╔══██╗██╔════╝ ██╔════╝████╗ ████║
9
+ ██████╔╝██████╔╝██║ ███╗ █████╗ ██╔████╔██║
10
+ ██╔══██╗██╔══██╗██║ ██║ ██╔══╝ ██║╚██╔╝██║
11
+ ██████╔╝██████╔╝╚██████╔╝██╗██║ ██║ ╚═╝ ██║
12
+ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝
13
+ `.trimStart();
14
+ export function Header() {
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: BANNER }), _jsx(Text, { dimColor: true, children: " Blockchain AI Radio \u2014 Terminal Player v0.1.0" })] }));
16
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * [INPUT]: ink Text/Box, Channel type, PlayerStatus, audioLevelRef, SpectrumVisualizer
3
+ * [OUTPUT]: Player component -- now playing display + spectrum + volume bar
4
+ * [POS]: Playback status panel, rendered by App
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ import { type MutableRefObject } from 'react';
8
+ import type { Channel } from '../data/channels.js';
9
+ import type { PlayerStatus } from '../hooks/usePlayer.js';
10
+ interface Props {
11
+ channel: Channel | null;
12
+ status: PlayerStatus;
13
+ volume: number;
14
+ audioLevelRef: MutableRefObject<number>;
15
+ error: string | null;
16
+ }
17
+ export declare function Player({ channel, status, volume, audioLevelRef, error }: Props): import("react/jsx-runtime").JSX.Element;
18
+ export {};
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { SpectrumVisualizer } from './SpectrumVisualizer.js';
4
+ // ============================================================
5
+ // Helpers
6
+ // ============================================================
7
+ function statusLabel(status) {
8
+ const map = {
9
+ idle: { text: 'IDLE', color: 'gray' },
10
+ connecting: { text: 'CONNECTING...', color: 'yellow' },
11
+ playing: { text: 'ON AIR', color: 'green' },
12
+ paused: { text: 'PAUSED', color: 'yellow' },
13
+ stopped: { text: 'STOPPED', color: 'red' },
14
+ error: { text: 'ERROR', color: 'red' },
15
+ };
16
+ return map[status];
17
+ }
18
+ function volumeBar(volume) {
19
+ const filled = Math.round(volume / 5);
20
+ const empty = 20 - filled;
21
+ return '█'.repeat(filled) + '░'.repeat(empty);
22
+ }
23
+ // ============================================================
24
+ // Component
25
+ // ============================================================
26
+ export function Player({ channel, status, volume, audioLevelRef, error }) {
27
+ const label = statusLabel(status);
28
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === 'playing' ? 'green' : 'gray', paddingX: 2, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "white", children: "NOW PLAYING" }), _jsxs(Text, { color: label.color, bold: true, children: ["[", label.text, "]"] })] }), channel ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, alignItems: "center", children: [_jsx(Text, { color: "green", bold: true, children: channel.name }), _jsx(SpectrumVisualizer, { status: status, audioLevelRef: audioLevelRef })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: channel.desc }) })] })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No channel selected \u2014 press Enter to play" }) })), _jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { dimColor: true, children: "VOL" }), _jsx(Text, { color: "green", children: volumeBar(volume) }), _jsxs(Text, { bold: true, children: [volume, "%"] })] }), error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) }))] }));
29
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * [INPUT]: PlayerStatus + audioLevelRef (shared MutableRefObject<number>) from usePlayer
3
+ * [OUTPUT]: SpectrumVisualizer -- ASCII waveform bars driven by real audio energy
4
+ * [POS]: Decorative element rendered inside Player card.
5
+ * Reads audioLevel from shared ref -- bypasses React render pipeline.
6
+ * Animation model (25fps, exp-decay 0.82, lerp-attack 0.7):
7
+ * 1. Playing + signal -> audio-reactive with per-bar jitter
8
+ * 2. Playing + silent -> simulated jitter over bell-curve envelope
9
+ * 3. Idle -> subtle breathing over bell-curve envelope
10
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
11
+ */
12
+ import { type MutableRefObject } from 'react';
13
+ import type { PlayerStatus } from '../hooks/usePlayer.js';
14
+ interface Props {
15
+ status: PlayerStatus;
16
+ audioLevelRef: MutableRefObject<number>;
17
+ }
18
+ export declare function SpectrumVisualizer({ status, audioLevelRef }: Props): import("react/jsx-runtime").JSX.Element;
19
+ export {};
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * [INPUT]: PlayerStatus + audioLevelRef (shared MutableRefObject<number>) from usePlayer
4
+ * [OUTPUT]: SpectrumVisualizer -- ASCII waveform bars driven by real audio energy
5
+ * [POS]: Decorative element rendered inside Player card.
6
+ * Reads audioLevel from shared ref -- bypasses React render pipeline.
7
+ * Animation model (25fps, exp-decay 0.82, lerp-attack 0.7):
8
+ * 1. Playing + signal -> audio-reactive with per-bar jitter
9
+ * 2. Playing + silent -> simulated jitter over bell-curve envelope
10
+ * 3. Idle -> subtle breathing over bell-curve envelope
11
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
12
+ */
13
+ import { useEffect, useRef, useState } from 'react';
14
+ import { Box, Text } from 'ink';
15
+ // ============================================================
16
+ // Constants
17
+ // ============================================================
18
+ const BAR_COUNT = 16;
19
+ const BLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
20
+ const TICK_MS = 40; // 25fps -- visual fluidity threshold
21
+ const ATTACK_LERP = 0.7; // aggressive rise -- near-instant transient response
22
+ const DECAY_FACTOR = 0.82; // fast exponential fall -- ~80ms to half-height
23
+ const FLOOR = 0.06; // minimum bar height
24
+ // Bell-curve envelope -- natural shape for idle / fallback
25
+ const ENVELOPE = (() => {
26
+ const out = [];
27
+ for (let i = 0; i < BAR_COUNT; i++) {
28
+ const t = i / (BAR_COUNT - 1);
29
+ const peak = Math.exp(-Math.pow((t - 0.55) * 2.8, 2)) * 0.95;
30
+ out.push(Math.max(FLOOR, peak));
31
+ }
32
+ return out;
33
+ })();
34
+ // Per-bar jitter frequencies -- avoid lockstep by giving each bar
35
+ // a unique oscillation speed and phase offset
36
+ const JITTER_FREQ = Array.from({ length: BAR_COUNT }, (_, i) => 3.1 + i * 0.37);
37
+ const JITTER_PHASE = Array.from({ length: BAR_COUNT }, (_, i) => i * 1.13);
38
+ // Green (#5BFF9F) at edges → Cyan (#00E5FF) at center
39
+ const BAR_COLORS = (() => {
40
+ const out = [];
41
+ for (let i = 0; i < BAR_COUNT; i++) {
42
+ const t = i / (BAR_COUNT - 1);
43
+ // Bell curve peaks at center → cyan; edges → green
44
+ const center = Math.exp(-Math.pow((t - 0.5) * 3.0, 2));
45
+ const r = Math.round(0x5b * (1 - center) + 0x00 * center);
46
+ const g = Math.round(0xff * (1 - center) + 0xe5 * center);
47
+ const b = Math.round(0x9f * (1 - center) + 0xff * center);
48
+ out.push(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`);
49
+ }
50
+ return out;
51
+ })();
52
+ // ============================================================
53
+ // Helpers
54
+ // ============================================================
55
+ function toBlock(value) {
56
+ const idx = Math.round(value * (BLOCKS.length - 1));
57
+ return BLOCKS[Math.max(0, Math.min(BLOCKS.length - 1, idx))] || ' ';
58
+ }
59
+ /** Clamp value to [FLOOR, 1] */
60
+ function clamp(v) {
61
+ return v < FLOOR ? FLOOR : v > 1 ? 1 : v;
62
+ }
63
+ export function SpectrumVisualizer({ status, audioLevelRef }) {
64
+ const [heights, setHeights] = useState([...ENVELOPE]);
65
+ const prevRef = useRef([...ENVELOPE]);
66
+ useEffect(() => {
67
+ const startTime = Date.now();
68
+ const timer = setInterval(() => {
69
+ const elapsed = Date.now() - startTime;
70
+ const t = elapsed / 1000;
71
+ const level = audioLevelRef.current; // direct ref read -- zero React overhead
72
+ const prev = prevRef.current;
73
+ const next = [];
74
+ const isActive = status === 'playing' || status === 'connecting';
75
+ const hasSignal = isActive && level > 0.01;
76
+ if (hasSignal) {
77
+ // ── State 1: Audio-reactive ──
78
+ // Exponential decay on fall, lerp on rise, per-bar jitter
79
+ for (let i = 0; i < BAR_COUNT; i++) {
80
+ const shape = ENVELOPE[i];
81
+ const jitter = Math.sin(t * JITTER_FREQ[i] + JITTER_PHASE[i]) * 0.18;
82
+ const target = clamp(level * shape + jitter * level);
83
+ // Rise: lerp toward target; Fall: exponential decay
84
+ const h = target > prev[i]
85
+ ? prev[i] + (target - prev[i]) * ATTACK_LERP
86
+ : Math.max(target, prev[i] * DECAY_FACTOR);
87
+ next.push(clamp(h));
88
+ }
89
+ }
90
+ else if (isActive) {
91
+ // ── State 2: Simulated fallback (playing but no signal) ──
92
+ for (let i = 0; i < BAR_COUNT; i++) {
93
+ const base = ENVELOPE[i];
94
+ const jitter = Math.sin(t * JITTER_FREQ[i] + JITTER_PHASE[i]) * 0.15;
95
+ next.push(clamp(base + jitter));
96
+ }
97
+ }
98
+ else {
99
+ // ── State 3: Idle breathing ──
100
+ for (let i = 0; i < BAR_COUNT; i++) {
101
+ const breath = Math.sin(t * 0.83 + i * 0.3) * 0.04;
102
+ next.push(clamp(ENVELOPE[i] + breath));
103
+ }
104
+ }
105
+ prevRef.current = next;
106
+ setHeights(next);
107
+ }, TICK_MS);
108
+ return () => clearInterval(timer);
109
+ }, [status]);
110
+ return (_jsx(Box, { gap: 1, children: heights.map((h, i) => (_jsx(Text, { color: BAR_COLORS[i], children: toBlock(h) }, i))) }));
111
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * [INPUT]: ink Text/Box
3
+ * [OUTPUT]: StatusBar component — keyboard shortcut help
4
+ * [POS]: Bottom bar with key bindings, rendered by App
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ export declare function StatusBar(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // ============================================================
4
+ // Component
5
+ // ============================================================
6
+ export function StatusBar() {
7
+ return (_jsxs(Box, { gap: 2, marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "j/k" }), " switch channel"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "Space" }), " play/stop"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "+/-" }), " volume"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "q" }), " quit"] })] }));
8
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * [INPUT]: None (pure data)
3
+ * [OUTPUT]: Channel type, CHANNELS array, DEFAULT_CHANNEL_ID
4
+ * [POS]: Data registry for radio HLS streams, consumed by usePlayer and ChannelList
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ export interface Channel {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ tag: string;
12
+ url: string;
13
+ desc: string;
14
+ }
15
+ export declare const CHANNELS: Channel[];
16
+ export declare const DEFAULT_CHANNEL_ID = "bbg-en";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * [INPUT]: None (pure data)
3
+ * [OUTPUT]: Channel type, CHANNELS array, DEFAULT_CHANNEL_ID
4
+ * [POS]: Data registry for radio HLS streams, consumed by usePlayer and ChannelList
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ export const CHANNELS = [
8
+ {
9
+ id: 'bbg-en',
10
+ slug: 'en',
11
+ name: 'BBG · ENGLISH',
12
+ tag: 'BLOCKCHAIN · NEWS & TALK',
13
+ url: 'https://koe.bbg.fm/channels/1/playlist.m3u8',
14
+ desc: 'Blockchain Live Broadcast | News · Analysis · Culture',
15
+ },
16
+ {
17
+ id: 'bbg-cn',
18
+ slug: 'zh',
19
+ name: 'BBG · 中文',
20
+ tag: '区块链 · 新闻资讯',
21
+ url: 'https://koe.bbg.fm/channels/2/playlist.m3u8',
22
+ desc: '区块链实时广播 | 新闻 · 深度 · 文化',
23
+ },
24
+ ];
25
+ export const DEFAULT_CHANNEL_ID = 'bbg-en';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * [INPUT]: node-mpv for HLS playback, Channel type from data/channels
3
+ * [OUTPUT]: usePlayer hook — play/pause/stop/setVolume/audioLevel + reactive state
4
+ * [POS]: Audio backend abstraction, consumed by App root component
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ import type { Channel } from '../data/channels.js';
8
+ export type PlayerStatus = 'idle' | 'connecting' | 'playing' | 'paused' | 'stopped' | 'error';
9
+ export declare function usePlayer(): {
10
+ audioLevelRef: import("react").MutableRefObject<number>;
11
+ play: (channel: Channel) => Promise<void>;
12
+ togglePause: () => Promise<void>;
13
+ adjustVolume: (delta: number) => void;
14
+ stop: () => Promise<void>;
15
+ status: PlayerStatus;
16
+ volume: number;
17
+ channel: Channel | null;
18
+ error: string | null;
19
+ };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * [INPUT]: node-mpv for HLS playback, Channel type from data/channels
3
+ * [OUTPUT]: usePlayer hook — play/pause/stop/setVolume/audioLevel + reactive state
4
+ * [POS]: Audio backend abstraction, consumed by App root component
5
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
6
+ */
7
+ import { useState, useCallback, useRef, useEffect } from 'react';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import NodeMpv from 'node-mpv';
11
+ // Cross-platform mpv IPC path
12
+ const MPV_SOCKET = process.platform === 'win32'
13
+ ? '\\\\.\\pipe\\bbgfm-mpv'
14
+ : join(tmpdir(), 'bbgfm-mpv.sock');
15
+ // ============================================================
16
+ // Hook — refs for mutable state, command chain for IPC safety
17
+ // ============================================================
18
+ export function usePlayer() {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- node-mpv .d.ts is malformed
20
+ const mpvRef = useRef(null);
21
+ const volumeRef = useRef(100);
22
+ const statusRef = useRef('idle');
23
+ const cmdChain = useRef(Promise.resolve());
24
+ const volTimer = useRef(null);
25
+ const levelTimer = useRef(null);
26
+ // Shared ref -- SpectrumVisualizer reads this directly, no React render hop
27
+ const audioLevelRef = useRef(0);
28
+ const [state, setState] = useState({
29
+ status: 'idle',
30
+ volume: 100,
31
+ channel: null,
32
+ error: null,
33
+ });
34
+ // Sync status ref + state
35
+ const setStatus = useCallback((s) => {
36
+ statusRef.current = s;
37
+ setState(prev => ({ ...prev, status: s }));
38
+ }, []);
39
+ // Serialize mpv IPC commands — prevents socket flooding
40
+ const runCmd = useCallback((fn) => {
41
+ cmdChain.current = cmdChain.current.catch(() => { }).then(fn).catch(() => { });
42
+ return cmdChain.current;
43
+ }, []);
44
+ // --------------------------------------------------------
45
+ // Audio level polling — reads astats Peak_level from mpv
46
+ // --------------------------------------------------------
47
+ const startLevelPolling = useCallback(() => {
48
+ if (levelTimer.current)
49
+ return;
50
+ let polling = false; // guard -- prevent async IPC overlap
51
+ let baseline = 0; // adaptive baseline (slow EMA of linear amplitude)
52
+ levelTimer.current = setInterval(async () => {
53
+ if (polling)
54
+ return;
55
+ const mpv = mpvRef.current;
56
+ if (!mpv?.isRunning())
57
+ return;
58
+ polling = true;
59
+ try {
60
+ const raw = await mpv.getProperty('af-metadata/astats/lavfi.astats.Overall.Peak_level');
61
+ const dB = parseFloat(raw);
62
+ if (isNaN(dB))
63
+ return;
64
+ // Convert dB to linear amplitude (0..1)
65
+ const linear = Math.pow(10, dB / 20);
66
+ // Adaptive baseline: slow-moving EMA (~2s time constant at 50ms intervals)
67
+ // Tracks the "floor" of the signal -- steady music sits near this
68
+ baseline = baseline < 0.001
69
+ ? linear
70
+ : baseline * 0.96 + linear * 0.04;
71
+ // Output = relative deviation from baseline, amplified
72
+ // For speech: linear spikes far above baseline -> high level
73
+ // For music: transients (drums, accents) spike above steady baseline
74
+ const deviation = linear - baseline * 0.7;
75
+ const headroom = Math.max(baseline * 1.5, 0.05);
76
+ audioLevelRef.current = Math.max(0, Math.min(1, deviation / headroom));
77
+ }
78
+ catch {
79
+ // af-metadata not available yet -- ignore silently
80
+ }
81
+ finally {
82
+ polling = false;
83
+ }
84
+ }, 50);
85
+ }, []);
86
+ const stopLevelPolling = useCallback(() => {
87
+ if (levelTimer.current) {
88
+ clearInterval(levelTimer.current);
89
+ levelTimer.current = null;
90
+ }
91
+ audioLevelRef.current = 0;
92
+ }, []);
93
+ // Lazy-init mpv — with astats audio filter for level metering
94
+ const getMpv = useCallback(() => {
95
+ if (!mpvRef.current) {
96
+ // @ts-expect-error — node-mpv .d.ts declares class but module shape is off
97
+ mpvRef.current = new NodeMpv({
98
+ audio_only: true,
99
+ verbose: false,
100
+ socket: MPV_SOCKET,
101
+ }, ['--af=@astats:lavfi=[astats=metadata=1:measure_overall=Peak_level+RMS_level:reset=1]']);
102
+ }
103
+ return mpvRef.current;
104
+ }, []);
105
+ // Play a channel
106
+ const play = useCallback(async (channel) => {
107
+ setStatus('connecting');
108
+ setState(prev => ({ ...prev, channel, error: null }));
109
+ await runCmd(async () => {
110
+ const mpv = getMpv();
111
+ if (!mpv.isRunning()) {
112
+ await mpv.start();
113
+ await mpv.volume(volumeRef.current);
114
+ }
115
+ await mpv.load(channel.url);
116
+ }).then(() => {
117
+ setStatus('playing');
118
+ startLevelPolling();
119
+ }, (err) => {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ setStatus('error');
122
+ setState(prev => ({ ...prev, error: msg }));
123
+ });
124
+ }, [getMpv, setStatus, runCmd, startLevelPolling]);
125
+ // Toggle pause/resume
126
+ const togglePause = useCallback(async () => {
127
+ const s = statusRef.current;
128
+ if (s !== 'playing' && s !== 'paused')
129
+ return;
130
+ await runCmd(async () => {
131
+ const mpv = getMpv();
132
+ if (!mpv.isRunning())
133
+ return;
134
+ if (s === 'playing') {
135
+ await mpv.pause();
136
+ setStatus('paused');
137
+ }
138
+ else {
139
+ await mpv.resume();
140
+ setStatus('playing');
141
+ }
142
+ });
143
+ }, [getMpv, setStatus, runCmd]);
144
+ // Volume — state updates instantly, mpv command debounced
145
+ const adjustVolume = useCallback((delta) => {
146
+ const next = Math.max(0, Math.min(100, volumeRef.current + delta));
147
+ volumeRef.current = next;
148
+ setState(prev => ({ ...prev, volume: next }));
149
+ // Debounce: only send the final value to mpv after 50ms of inactivity
150
+ if (volTimer.current)
151
+ clearTimeout(volTimer.current);
152
+ volTimer.current = setTimeout(() => {
153
+ runCmd(async () => {
154
+ const mpv = getMpv();
155
+ if (mpv.isRunning())
156
+ await mpv.volume(volumeRef.current);
157
+ });
158
+ }, 50);
159
+ }, [getMpv, runCmd]);
160
+ // Stop and quit mpv
161
+ const stop = useCallback(async () => {
162
+ stopLevelPolling();
163
+ if (volTimer.current)
164
+ clearTimeout(volTimer.current);
165
+ await runCmd(async () => {
166
+ const mpv = getMpv();
167
+ if (mpv.isRunning()) {
168
+ await mpv.stop();
169
+ await mpv.quit();
170
+ }
171
+ });
172
+ mpvRef.current = null;
173
+ setStatus('stopped');
174
+ }, [getMpv, setStatus, runCmd, stopLevelPolling]);
175
+ // Cleanup on unmount
176
+ useEffect(() => {
177
+ return () => {
178
+ if (levelTimer.current)
179
+ clearInterval(levelTimer.current);
180
+ if (volTimer.current)
181
+ clearTimeout(volTimer.current);
182
+ const mpv = mpvRef.current;
183
+ if (mpv?.isRunning()) {
184
+ mpv.stop().then(() => mpv.quit()).catch(() => { });
185
+ }
186
+ };
187
+ }, []);
188
+ return { ...state, audioLevelRef, play, togglePause, adjustVolume, stop };
189
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * [INPUT]: ink render, App component, child_process for mpv check
4
+ * [OUTPUT]: CLI entry point -- parses args, checks mpv, renders App
5
+ * [POS]: Executable entry, registered as bin in package.json
6
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
7
+ */
8
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * [INPUT]: ink render, App component, child_process for mpv check
5
+ * [OUTPUT]: CLI entry point -- parses args, checks mpv, renders App
6
+ * [POS]: Executable entry, registered as bin in package.json
7
+ * [PROTOCOL]: Update this header on changes, then check AGENTS.md
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { render } from 'ink';
11
+ import { App } from './app.js';
12
+ // ============================================================
13
+ // Preflight -- mpv is required for audio playback
14
+ // ============================================================
15
+ function ensureMpv() {
16
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
17
+ try {
18
+ execFileSync(cmd, ['mpv'], { stdio: 'ignore' });
19
+ }
20
+ catch {
21
+ console.error('\n mpv is required but not found.\n');
22
+ console.error(' Install via:');
23
+ console.error(' brew install mpv # macOS');
24
+ console.error(' sudo apt install mpv # Debian/Ubuntu');
25
+ console.error(' sudo pacman -S mpv # Arch');
26
+ console.error(' scoop install mpv # Windows (scoop)');
27
+ console.error(' choco install mpv # Windows (choco)');
28
+ console.error(' winget install mpv # Windows (winget)\n');
29
+ process.exit(1);
30
+ }
31
+ }
32
+ // ============================================================
33
+ // Arg Parsing (minimal, no dependency)
34
+ // ============================================================
35
+ function parseArgs() {
36
+ const args = process.argv.slice(2);
37
+ const channelIdx = args.indexOf('--channel');
38
+ const channel = channelIdx !== -1 ? args[channelIdx + 1] : undefined;
39
+ return { channel };
40
+ }
41
+ // ============================================================
42
+ // Boot
43
+ // ============================================================
44
+ ensureMpv();
45
+ const { channel } = parseArgs();
46
+ render(_jsx(App, { initialChannel: channel }));
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "bbgfm",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "bin": {
9
+ "bbgfm": "./dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "dev": "tsx src/index.tsx",
13
+ "build": "tsc",
14
+ "start": "node dist/index.js",
15
+ "prepublishOnly": "pnpm build"
16
+ },
17
+ "dependencies": {
18
+ "ink": "^5.2.0",
19
+ "node-mpv": "^2.0.0-beta.2",
20
+ "react": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.19.13",
24
+ "@types/react": "^18.3.18",
25
+ "tsx": "^4.19.2",
26
+ "typescript": "^5.7.3"
27
+ }
28
+ }