@ciphore/radiocli 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to RadioCLI are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-05-30
9
+
10
+ Initial public release.
11
+
12
+ ### Added
13
+
14
+ - A square braille spinner now animates to the left of the station name in the
15
+ live playback footer while a station is buffering.
16
+ - Ink-based terminal UI with overview, now playing, library, explore, search,
17
+ countries, world map, nearby, stats, and settings screens.
18
+ - Radio Browser provider with mirror fallback, durable cache, and tune
19
+ resolution, plus an experimental, opt-in Radio Garden provider.
20
+ - `mpv` playback with `ffplay` fallback, readiness checks, ICY metadata polling,
21
+ volume, pause, mute, sleep timer, tune timeouts, and skip-broken-stream
22
+ behavior.
23
+ - Receiver-style Now Playing screen with 50 selectable visualizers and
24
+ zero-signal frames whenever playback is idle, paused, stopped, or not
25
+ backend-ready.
26
+ - Explore map: a braille world map beside the live station list with WASD
27
+ movement, click-to-place mouse support, and a cached geotagged station atlas
28
+ ranked by distance.
29
+ - Local-first library: recents, favorites, imports, listening activity,
30
+ settings, learned media keys, and provider cache stored as JSON on your
31
+ machine.
32
+ - Playlist import/export for `.m3u`, `.pls`, and `.xspf`.
33
+ - Listening stats with a 52-week local-calendar contribution graph, sessions,
34
+ streaks, active days, and total hours listened.
35
+ - `radiocli doctor` to report local playback readiness and print OS-specific
36
+ setup guidance.
37
+ - Homebrew formula template for `brew install` distribution.
38
+
39
+ ### Changed
40
+
41
+ - Tightened the Now Playing panel below the receiver: collapsed the stream
42
+ tech/tags, track metadata, and backend/volume rows into two compact lines
43
+ (track + favorite, then a single tech · tags · sleep line), dropping
44
+ backend/volume that already appear in the header and footer. The reclaimed
45
+ rows go to the visualizer.
46
+
47
+ [0.1.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.0
@@ -0,0 +1,13 @@
1
+ # Code of Conduct
2
+
3
+ RadioCLI follows the Contributor Covenant standard in spirit: be respectful,
4
+ constructive, and precise. This project welcomes users and contributors who care
5
+ about terminal software, radio, resilient public APIs, and thoughtful product
6
+ craft.
7
+
8
+ Unacceptable behavior includes harassment, personal attacks, deliberate
9
+ disruption, doxxing, or dismissive conduct toward people reporting issues in
10
+ good faith.
11
+
12
+ Project maintainers may remove comments, close issues, or block participants
13
+ when behavior makes the project less useful or less welcoming.
@@ -0,0 +1,56 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution. RadioCLI is intentionally small, but
4
+ the bar is high: changes should improve the product, preserve terminal
5
+ ergonomics, and respect the unreliability of public radio streams.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ npm ci
11
+ npm run build
12
+ npm run test
13
+ ```
14
+
15
+ Install `mpv` for the best playback experience:
16
+
17
+ ```bash
18
+ brew install mpv
19
+ ```
20
+
21
+ On Linux, use your package manager's `mpv` package.
22
+
23
+ ## Verification
24
+
25
+ Run these before opening a PR:
26
+
27
+ ```bash
28
+ npm run check
29
+ npm run lint
30
+ npm run test
31
+ npm run build
32
+ ```
33
+
34
+ Run `npm run smoke:playback` when changing playback code. It opens a public
35
+ stream briefly, so do not run it in environments where audio/network access is
36
+ not acceptable.
37
+
38
+ Live provider checks are useful before releases, but ordinary pull requests
39
+ should stay deterministic. Run `npm run verify:release` for a full maintainer
40
+ pre-release pass that includes live data, playback, packaging, and fresh-install
41
+ checks.
42
+
43
+ ## Development Principles
44
+
45
+ - Keep public provider adapters isolated.
46
+ - Treat station streams as unreliable.
47
+ - Never make location lookup implicit.
48
+ - Prefer clear terminal text over decorative filler.
49
+ - Keep command-line and TUI behavior aligned.
50
+ - Add tests for parsing, storage, layout, or provider transformations when the
51
+ behavior can be checked without live audio.
52
+
53
+ ## Issue Triage
54
+
55
+ Playback issues should include station name, country, backend, and
56
+ `radiocli check` output when possible.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ciphore
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # RadioCLI
2
+
3
+ [![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](package.json)
6
+
7
+ RadioCLI is a terminal-first world radio receiver for exploring live public stations, tuning real streams, and keeping your listening history close to the command line.
8
+
9
+ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://react.dev/), [Radio Browser](https://api.radio-browser.info/), and `mpv`. The goal is not a thin wrapper around a station list. The goal is a polished TUI product: fast discovery, resilient stream handling, local-first library state, and the kind of engineering surface that can grow without turning into terminal spaghetti.
10
+
11
+ ## Features
12
+
13
+ - Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
14
+ - Tune stations with `mpv` first and `ffplay` fallback when available.
15
+ - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
16
+ - Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
17
+ - Move previous/next through the exact station list you tuned from, even after navigating to another screen.
18
+ - Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
19
+ - Search by station name, place, language, tag, codec, or minimum bitrate.
20
+ - Keep local recents, favorites, imported stations, listening activity, playback settings, learned media keys, and provider cache.
21
+ - Review listening stats with a GitHub-style local-calendar contribution graph, favorite station, thresholded stations listened, sessions, streaks, active days, and total hours listened from persisted sessions.
22
+ - Import `.m3u`, `.pls`, and `.xspf` playlists, including nested local playlists.
23
+ - Export favorites and imports as `.m3u`.
24
+ - Survive ordinary internet-radio failure modes with provider mirror fallback, stale cache fallback, corrupt-file backups, tune timeouts, and skip-broken-stream behavior.
25
+ - Resize with the terminal. The app listens for terminal resize events and recomputes list row counts, map density, receiver width, and compact-mode fallback from the current dimensions.
26
+
27
+ ## Demo
28
+
29
+ The interactive TUI opens directly into the product, not a marketing screen:
30
+
31
+ ```text
32
+ ┌ RADIOCLI ──────────────────────────────────────────────────────────┐
33
+ │ Overview │ Playing │ Library │ Explore │ Search │ Countries │ … │ idle │
34
+ └──────────────────────────────────────────────────────────────────────┘
35
+ RADIOCLI ██████████████████
36
+ Live public radio from around the world
37
+
38
+ > 1 Playing · Receiver display and controls
39
+ 2 Library · Favorites, recent stations, imported streams
40
+ 3 Explore · Move a map cursor through geotagged stations
41
+ 4 Search · Find stations by name, genre, language, place
42
+ 5 Countries · Browse by country list with a world-map toggle
43
+ 6 Nearby · Opt-in approximate location for local stations
44
+ 7 Stats · Listening graph, sessions, streaks, hours
45
+ 8 Settings · Playback backend, colors, providers
46
+
47
+ 3 recent · 2 favorites · 1 imported
48
+
49
+ ↑/↓ move · Enter open · number jump · : command
50
+ ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
51
+ ```
52
+
53
+ The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
54
+
55
+ - **Classic receiver** — equalizer, LEDs, and goniometer.
56
+ - **High-resolution braille** — smooth waveform, radial EQ, spectrogram, nebula, silk, ripple tank, phyllotaxis, harmonograph, bloom bars, moiré, galaxy, caustics.
57
+ - **Generative & motion** — matrix, hologram, spinning ASCII cube, generated fire, fireworks, plasma, spinning donut, starfield, Lorenz attractor, Barnsley fern, Chladni plate, rotating tesseract, torus knot, fractal tree, Julia sets, and lava-lamp motion.
58
+
59
+ Visualizers animate only while playback is actually playing and backend-ready; paused, stopped, idle, loading, and error states render a flat zero-signal display instead of a frozen waveform:
60
+
61
+ ```text
62
+ Now playing ──────────────────────────────────────────────── mpv · playing
63
+ ╭────────────────────────────────────────────────────────────────────────╮
64
+ │ FM 128.M RADIOCLI PLAYING │
65
+ │ KEXP 90.3 FM │
66
+ │ UNITED STATES · WASHINGTON │
67
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
68
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
69
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
70
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
71
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
72
+ │ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
73
+ │ MP3 · 128 kbps · english │
74
+ │ alternative, indie, seattle │
75
+ │ Waiting for ICY track metadata │
76
+ │ Backend mpv · Vol 70 ☆ Add favorite (f) Sleep off │
77
+ ╰────────────────────────────────────────────────────────────────────────╯
78
+
79
+ Playing: KEXP 90.3 FM · Now: Artist - Track · Seattle, Washington, United States · MP3 / 128 kbps / english · mpv · playing · vol 70 · Nearby 4/90
80
+ space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home
81
+ ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
82
+ ```
83
+
84
+ For the exact non-interactive demo transcript:
85
+
86
+ ```bash
87
+ npm run demo:script
88
+ ```
89
+
90
+ Recording instructions live in [the demo guide](apps/docs/content/docs/demo.mdx).
91
+
92
+ ## Documentation Website
93
+
94
+ The public website and the manual now live together in `apps/docs`, a Fumadocs-powered Next app. It renders the homepage at `/`, the documentation tree at `/docs`, docs search at `/api/search`, generated page images, and LLM-readable text routes.
95
+
96
+ Docs content is written as MDX in `apps/docs/content/docs`, with the navigation tree defined by the local `meta.json` files.
97
+
98
+ Useful commands from the repo root:
99
+
100
+ ```bash
101
+ npm run docs:dev
102
+ npm run docs:check
103
+ npm run docs:build
104
+ ```
105
+
106
+ Set `NEXT_PUBLIC_SITE_URL` for the canonical public docs URL. Preview builds also read Vercel's `VERCEL_URL` and Cloudflare Pages' `CF_PAGES_URL`.
107
+
108
+ ## Install
109
+
110
+ Requirements:
111
+
112
+ - Node.js 22 or newer
113
+ - `mpv` for best playback; RadioCLI expects one local playback backend at runtime
114
+ - `ffplay` from FFmpeg as an optional fallback
115
+
116
+ NPM installs RadioCLI and its JavaScript dependencies. It does not install native
117
+ system playback tools. Use `radiocli doctor` after installation to check local
118
+ playback readiness and get the right setup command for your OS:
119
+
120
+ ```bash
121
+ npm install -g @ciphore/radiocli
122
+ radiocli doctor
123
+ radiocli
124
+ ```
125
+
126
+ macOS with npm:
127
+
128
+ ```bash
129
+ brew install mpv
130
+ npm install -g @ciphore/radiocli
131
+ radiocli
132
+ ```
133
+
134
+ Linux with npm:
135
+
136
+ ```bash
137
+ sudo apt install mpv
138
+ npm install -g @ciphore/radiocli
139
+ radiocli
140
+ ```
141
+
142
+ `ffplay` is optional fallback support. Install FFmpeg separately if you want it:
143
+
144
+ ```bash
145
+ brew install ffmpeg # macOS
146
+ sudo apt install ffmpeg # Debian/Ubuntu
147
+ ```
148
+
149
+ The repo also includes a Homebrew formula template in `packaging/homebrew` for a
150
+ native one-command macOS tap. Once the tap formula is published, the intended
151
+ Homebrew install path is:
152
+
153
+ ```bash
154
+ brew install ciphore/tap/radiocli
155
+ ```
156
+
157
+ That formula depends on `node` and `mpv`, keeping native dependencies in the
158
+ native package manager instead of running system installs from npm.
159
+
160
+ CI covers command-mode typecheck, tests, builds, and package checks on Ubuntu. Native Windows terminals are not release-tested yet; use WSL with Linux `mpv` / `ffplay` for the supported path.
161
+
162
+ Local checkout:
163
+
164
+ ```bash
165
+ git clone https://github.com/Ciphore/RadioCLI.git
166
+ cd RadioCLI
167
+ npm ci
168
+ npm run build
169
+ npm link
170
+ radiocli
171
+ ```
172
+
173
+ If you do not want to link the package globally:
174
+
175
+ ```bash
176
+ npm run dev
177
+ ```
178
+
179
+ ## CLI Usage
180
+
181
+ ```bash
182
+ radiocli # Start the TUI
183
+ radiocli check # Show local store path, playback backends, provider health
184
+ radiocli doctor # Show playback setup status and install guidance
185
+ radiocli countries # Print top countries by station count
186
+ radiocli search "japan hits"
187
+ radiocli import stations.m3u
188
+ radiocli export favorites.m3u
189
+ radiocli add-url <stream-url> [station name]
190
+ ```
191
+
192
+ `radiocli export` writes `radiocli-favorites.m3u` when no output path is provided.
193
+
194
+ After a local build, the same commands can be run with:
195
+
196
+ ```bash
197
+ node dist/cli.js check
198
+ node dist/cli.js search "lagos talk"
199
+ ```
200
+
201
+ ## TUI Controls
202
+
203
+ RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active, a compact live row sits above the shortcuts with station, cleaned track metadata, volume or mute state, and an active sleep timer. The page shortcut row changes with the current screen, and the global transport row stays global:
204
+
205
+ - `←` / `→` or `Tab` / `Shift+Tab`: move across the top screen tabs.
206
+ - `F7` / `F9`, `,` / `.`, or `Shift+←` / `Shift+→`: tune previous or next station from the source list, wherever you are in the TUI.
207
+ - `space` / `F8`: pause or resume.
208
+ - `t`: cycle display color.
209
+ - `v`: cycle receiver style.
210
+ - `+` / `-`: volume.
211
+ - `q` or `Ctrl+C`: quit cleanly.
212
+
213
+ Page-specific footer controls:
214
+
215
+ | Screen | Controls |
216
+ | --- | --- |
217
+ | Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
218
+ | Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
219
+ | Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
220
+ | Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
221
+ | Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
222
+ | World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
223
+ | Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
224
+ | Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
225
+ | Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
226
+ | Stats | `b` home |
227
+
228
+ Other active shortcuts:
229
+
230
+ - `Enter`: open the selected item or tune the selected station without leaving the current list.
231
+ - `:`: command palette.
232
+ - `/`: edit search or country filter on screens that support it.
233
+ - `[` / `]`: page through long station and country lists.
234
+ - `m`: mute.
235
+ - `o`: cycle playback backend.
236
+ - `g`: toggle the experimental Radio Garden adapter.
237
+ - `l`: toggle nearby location lookup.
238
+ - `x`: toggle skip-broken-stream behavior.
239
+ - `r`: refresh provider health.
240
+ - `f`: favorite the current or selected station.
241
+ - `n` / `p`: move selection; on Now Playing, tune next or previous station from the source list.
242
+ - `s`: cycle the sleep timer on Now Playing through off, 15 minutes, 30 minutes, 60 minutes, then off again.
243
+ - `d`: stream diagnostics on Now Playing.
244
+ - `b`: back home.
245
+
246
+ When you tune a station from Library, Explore, Search, Countries, or Nearby, that list becomes the playback queue. Previous/next keeps moving through that source list from any screen until you tune from another list.
247
+
248
+ Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
249
+
250
+ Explore mouse clicks use terminal mouse reporting while the Explore tab is active. If your terminal or tmux setup does not pass those events through, the WASD cursor controls stay fully available.
251
+
252
+ Useful command palette entries:
253
+
254
+ ```text
255
+ :search lagos jazz
256
+ :country japan
257
+ :codec MP3
258
+ :language spanish
259
+ :bitrate 128
260
+ :clear
261
+ :volume 60
262
+ :mute
263
+ :favorite
264
+ :sleep 15
265
+ :sleep off
266
+ :timeout 15
267
+ :skip off
268
+ :location on
269
+ :learn previous
270
+ :learn play
271
+ :learn next
272
+ :keys reset
273
+ :map
274
+ :library
275
+ :stats
276
+ :settings
277
+ :stop
278
+ ```
279
+
280
+ Settings persist display colors and receiver styles without editing config files. The fourteen display colors are green, amber, blue, ruby, ice, teal, violet, copper, cyan, lime, coral, rose, slate, and mono, cycled with `t`. The 50 receiver styles span classic receiver displays, high-resolution braille visuals, and generative motion scenes; cycle them with `v` (see the [Demo](#demo) for the full family breakdown). The stats graph and legend follow the selected display color, and the selected Now Playing style is restored on the next launch.
281
+
282
+ ## Architecture
283
+
284
+ RadioCLI is split around four seams:
285
+
286
+ - TUI state and screens in `src/ui`
287
+ - provider adapters in `src/providers`
288
+ - playback lifecycle and metadata in `src/player`
289
+ - local JSON persistence in `src/storage`
290
+
291
+ Radio Browser is the primary provider. Its own docs recommend using a speaking user agent, resolving station clicks through `/json/url`, and retrying with other servers when one fails; RadioCLI follows that shape with mirror fallback and durable cache. Explore and Nearby use a cached geotagged Radio Browser atlas, then compute local distance in the app so map movement is not biased toward the most-clicked stations worldwide. Radio Garden support is experimental because the useful endpoints are publicly discoverable but unofficial, and they can be blocked or changed independently of this project.
292
+
293
+ Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling.
294
+
295
+ The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. Current installs store data under RadioCLI paths such as `radiocli.json` and `radiocli-cache.json`. Existing Radio Atlas data is still discovered when a new RadioCLI store does not exist, and legacy `RADIO_ATLAS_HOME` / animation environment variables remain supported as migration fallbacks. New automation should use `RADIOCLI_HOME` and `RADIOCLI_DISABLE_ANIMATION`.
296
+
297
+ Read more:
298
+
299
+ - [Architecture](apps/docs/content/docs/architecture.mdx)
300
+ - [Design notes](apps/docs/content/docs/design.mdx)
301
+ - [Reliability](apps/docs/content/docs/reliability.mdx)
302
+ - [Roadmap](apps/docs/content/docs/roadmap.mdx)
303
+ - [Release packaging](apps/docs/content/docs/release-packaging.mdx)
304
+
305
+ These also render as a browsable site — run `npm run docs:dev` (see `apps/docs`).
306
+
307
+ ## Engineering Highlights
308
+
309
+ This repo is intentionally small, but it is built like production software:
310
+
311
+ - provider boundary instead of UI-coupled fetch calls
312
+ - cached geotagged station atlas for true distance-first Explore and Nearby results
313
+ - Zod schemas at public API and persistence boundaries
314
+ - stale-cache fallback for directory outages
315
+ - corrupt store/cache backup instead of silent overwrite
316
+ - `mpv` readiness checks before reporting playback as active
317
+ - tune timeout and skip-broken-stream behavior
318
+ - cleaned ICY metadata, including key/value payloads such as `title="..." artist="..."` and station-specific `text="..."` fields
319
+ - source-list playback queues for previous/next transport
320
+ - enhanced terminal keyboard parsing with learned media-key bindings
321
+ - local-calendar activity bucketing for late-night listening sessions
322
+ - local-first privacy posture for history, favorites, imports, and settings
323
+ - responsive terminal layout utility with an adaptive footer and focused tests
324
+ - smoke tests that exercise live provider data and real playback
325
+ - package smoke test that packs the npm artifact, installs it into a fresh temp project, and runs the installed binary
326
+
327
+ ## Privacy
328
+
329
+ Nearby station discovery is off by default. If you enable it, RadioCLI requests approximate IP-based location from `ipapi.co` and uses the returned city/region/country/coordinates to sort the local geotagged station atlas. Explore uses only the cursor coordinate you move in the terminal. The app does not require an account, does not store secrets, and does not proxy audio. It stores recents, favorites, imports, settings, and provider cache data locally on your machine.
330
+
331
+ ## Development
332
+
333
+ ```bash
334
+ npm ci
335
+ npm run check
336
+ npm run lint
337
+ npm run test
338
+ npm run build
339
+ npm run docs:check
340
+ npm run docs:build
341
+ npm run smoke:data
342
+ npm run verify
343
+ npm run smoke:playback
344
+ npm run pack:check
345
+ npm run fresh:check
346
+ npm run verify:release
347
+ ```
348
+
349
+ `npm run smoke:playback` briefly opens a public stream through your local playback backend.
350
+
351
+ ## Contributing
352
+
353
+ Contributions are welcome when they keep the app practical, reliable, and honest about public radio streams. Start with [CONTRIBUTING.md](CONTRIBUTING.md), and include `radiocli check` output for playback issues.
354
+
355
+ ## References
356
+
357
+ - [Ink README](https://github.com/vadimdemedes/ink)
358
+ - [Radio Browser API docs](https://api.radio-browser.info/)
359
+ - [mpv JSON IPC manual](https://mpv.io/manual/stable/#json-ipc)
360
+ - [Unofficial Radio Garden OpenAPI notes](https://github.com/jonasrmichel/radio-garden-openapi)
361
+
362
+ ## License
363
+
364
+ MIT. See [LICENSE](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,31 @@
1
+ # Security
2
+
3
+ RadioCLI does not require secrets or accounts.
4
+
5
+ ## Reporting
6
+
7
+ Open a private security advisory on GitHub:
8
+
9
+ ```text
10
+ https://github.com/Ciphore/RadioCLI/security/advisories
11
+ ```
12
+
13
+ If private reporting is unavailable, open a minimal public issue asking for a
14
+ private disclosure path, but do not include exploit details. Please avoid filing
15
+ public issues for vulnerabilities that could expose local files, private network
16
+ information, or unexpected command execution.
17
+
18
+ ## Privacy Notes
19
+
20
+ - Nearby station discovery is opt-in.
21
+ - Location lookup uses approximate IP-based location from `ipapi.co` when
22
+ enabled.
23
+ - The app stores recents, favorites, settings, imports, and provider cache data
24
+ locally under the user data directory.
25
+ - RadioCLI does not proxy audio. It resolves public stream URLs and hands
26
+ playback to `mpv` or `ffplay`.
27
+
28
+ ## Supported Versions
29
+
30
+ Until a tagged release cadence is established, security fixes target the active
31
+ development branch and the latest public npm release.
@@ -0,0 +1,122 @@
1
+ import { stationKey } from '../storage/store.js';
2
+ const trackedDays = 371;
3
+ const listenedStationThresholdSeconds = 120;
4
+ export function computeListeningStats(sessions, now = new Date()) {
5
+ const today = startOfLocalDay(now);
6
+ const firstDay = addLocalDays(today, -(trackedDays - 1));
7
+ const lastDayEnd = addLocalDays(today, 1);
8
+ const secondsByDay = new Map();
9
+ const secondsByStation = new Map();
10
+ let totalSeconds = 0;
11
+ for (const session of sessions) {
12
+ const seconds = sessionSeconds(session, now);
13
+ if (seconds <= 0) {
14
+ continue;
15
+ }
16
+ totalSeconds += seconds;
17
+ for (const allocation of splitSessionByDay(session, seconds, firstDay, lastDayEnd, now)) {
18
+ secondsByDay.set(allocation.date, (secondsByDay.get(allocation.date) ?? 0) + allocation.seconds);
19
+ }
20
+ const key = stationKey(session.station);
21
+ const current = secondsByStation.get(key) ?? { station: session.station, seconds: 0 };
22
+ secondsByStation.set(key, { station: current.station, seconds: current.seconds + seconds });
23
+ }
24
+ const days = Array.from({ length: trackedDays }, (_, index) => {
25
+ const date = addLocalDays(firstDay, index);
26
+ const key = localDay(date);
27
+ return { date: key, seconds: secondsByDay.get(key) ?? 0 };
28
+ });
29
+ const activeDays = days.filter(day => day.seconds > 0).length;
30
+ let favorite = null;
31
+ for (const entry of secondsByStation.values()) {
32
+ if (!favorite || entry.seconds > favorite.seconds) {
33
+ favorite = entry;
34
+ }
35
+ }
36
+ return {
37
+ favoriteStation: favorite?.station ?? null,
38
+ listenedStationCount: Array.from(secondsByStation.values()).filter(entry => entry.seconds >= listenedStationThresholdSeconds).length,
39
+ sessions: sessions.length,
40
+ currentStreak: currentStreak(days),
41
+ activeDays,
42
+ totalTrackedDays: trackedDays,
43
+ totalSeconds,
44
+ longestStreak: longestStreak(days),
45
+ days
46
+ };
47
+ }
48
+ function sessionSeconds(session, now = new Date()) {
49
+ const started = Date.parse(session.startedAt);
50
+ const ended = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
51
+ if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) {
52
+ return Math.max(0, Math.round(session.listenedSeconds));
53
+ }
54
+ return Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000));
55
+ }
56
+ function localDay(date) {
57
+ const year = date.getFullYear();
58
+ const month = String(date.getMonth() + 1).padStart(2, '0');
59
+ const day = String(date.getDate()).padStart(2, '0');
60
+ return `${year}-${month}-${day}`;
61
+ }
62
+ function splitSessionByDay(session, seconds, firstDay, lastDayEnd, now) {
63
+ const started = Date.parse(session.startedAt);
64
+ if (!Number.isFinite(started)) {
65
+ return [];
66
+ }
67
+ const recordedEnd = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
68
+ const rawEnd = Number.isFinite(recordedEnd) && recordedEnd > started
69
+ ? recordedEnd
70
+ : started + seconds * 1000;
71
+ const end = Math.max(started, rawEnd);
72
+ const boundedStart = Math.max(started, firstDay.getTime());
73
+ const boundedEnd = Math.min(end, lastDayEnd.getTime());
74
+ if (boundedEnd <= boundedStart) {
75
+ return [];
76
+ }
77
+ const allocations = [];
78
+ let cursor = boundedStart;
79
+ while (cursor < boundedEnd) {
80
+ const dayStart = startOfLocalDay(new Date(cursor)).getTime();
81
+ const nextDay = addLocalDays(new Date(dayStart), 1).getTime();
82
+ const segmentEnd = Math.min(nextDay, boundedEnd);
83
+ allocations.push({
84
+ date: localDay(new Date(cursor)),
85
+ seconds: Math.round((segmentEnd - cursor) / 1000)
86
+ });
87
+ cursor = segmentEnd;
88
+ }
89
+ return allocations;
90
+ }
91
+ function startOfLocalDay(date) {
92
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
93
+ }
94
+ function addLocalDays(date, days) {
95
+ const next = new Date(date.getTime());
96
+ next.setDate(next.getDate() + days);
97
+ return startOfLocalDay(next);
98
+ }
99
+ function currentStreak(days) {
100
+ let streak = 0;
101
+ for (let index = days.length - 1; index >= 0; index -= 1) {
102
+ if (days[index].seconds <= 0) {
103
+ break;
104
+ }
105
+ streak += 1;
106
+ }
107
+ return streak;
108
+ }
109
+ function longestStreak(days) {
110
+ let longest = 0;
111
+ let current = 0;
112
+ for (const day of days) {
113
+ if (day.seconds > 0) {
114
+ current += 1;
115
+ longest = Math.max(longest, current);
116
+ }
117
+ else {
118
+ current = 0;
119
+ }
120
+ }
121
+ return longest;
122
+ }