@ciphore/radiocli 0.2.0 → 0.2.1

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 (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +36 -2
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +9 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +93 -21
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -31,3 +31,10 @@ SOFTWARE.
31
31
 
32
32
  The land polygon data is derived from Natural Earth 110m land data. Natural
33
33
  Earth vector data is public domain.
34
+
35
+ ## node-airtunes2
36
+
37
+ AirPlay support is provided as an optional integration with `node-airtunes2`
38
+ 2.5.0, distributed under the GNU Affero General Public License v3. Its source,
39
+ license text, and attribution are available in the installed dependency and at
40
+ <https://github.com/bertrandda/node-airtunes2>.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,7 +73,7 @@
73
73
  "protobufjs": "^7.5.8"
74
74
  },
75
75
  "devDependencies": {
76
- "@types/node": "^25.9.1",
76
+ "@types/node": "^22.15.0",
77
77
  "@types/react": "^19.2.15",
78
78
  "ink-testing-library": "^4.0.0",
79
79
  "knip": "^6.14.2",
@@ -1,235 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { render } from 'ink-testing-library';
3
- import { describe, expect, it } from 'vitest';
4
- import { DisplayContext, resolveDisplayMode } from '../display-context.js';
5
- import { HelpScreen } from './HelpScreen.js';
6
- import { NowPlayingScreen } from './NowPlayingScreen.js';
7
- import { SettingsScreen } from './SettingsScreen.js';
8
- import { ExploreScreen } from './ExploreScreen.js';
9
- import { CountriesScreen } from './CountriesScreen.js';
10
- import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
11
- import { settingsItems } from '../screen-items.js';
12
- import { defaultExploreCursor } from '../app-state.js';
13
- const station = {
14
- id: 'station-1',
15
- provider: 'radio-browser',
16
- name: 'KEXP 90.3 FM',
17
- country: 'United States',
18
- tags: ['indie'],
19
- codec: 'MP3',
20
- bitrate: 128
21
- };
22
- const playback = {
23
- backend: 'mpv',
24
- state: 'idle',
25
- volume: 70,
26
- muted: false,
27
- ready: false
28
- };
29
- const diagnostics = {
30
- backend: 'mpv',
31
- availableBackends: ['mpv'],
32
- preferredBackend: 'auto',
33
- active: false,
34
- volume: 70,
35
- muted: false,
36
- ready: false
37
- };
38
- const trackHistory = [
39
- { title: 'Bjork - Joga', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '2' },
40
- { title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '1' }
41
- ];
42
- function renderNowPlaying(asciiMode, showDiagnostics) {
43
- const mode = resolveDisplayMode({ asciiMode }, {});
44
- return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(NowPlayingScreen, { station: station, playback: playback, metadata: null, theme: "green", favorite: true, pulse: 0, diagnostics: diagnostics, sleepLabel: "Sleep off", showDiagnostics: showDiagnostics, stationTime: "12:00", receiverStyle: "pulse-grid", trackHistory: trackHistory, width: 72, height: 30 }) }));
45
- }
46
- const settings = {
47
- theme: 'green',
48
- receiverStyle: 'pulse-grid',
49
- volume: 70,
50
- enableRadioGarden: false,
51
- enableNearbyLocation: false,
52
- preferredBackend: 'auto',
53
- tuneTimeoutSeconds: 12,
54
- skipBrokenStreams: true,
55
- mediaKeys: { previous: [], playPause: [], next: [] },
56
- resumeOnLaunch: true,
57
- asciiMode: true,
58
- reduceMotion: false,
59
- transparentBackground: false
60
- };
61
- describe('SettingsScreen rendering', () => {
62
- it('renders the new display and playback toggles with their values', () => {
63
- const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
64
- const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
65
- const frame = lastFrame() ?? '';
66
- expect(frame).toContain('Resume last station on launch');
67
- expect(frame).toContain('ASCII-safe display');
68
- expect(frame).toContain('Reduce motion');
69
- expect(frame).toContain('Transparent background');
70
- });
71
- it('changes the update settings row when an update is available', () => {
72
- const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
73
- const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.10', updateAvailable: true }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
74
- const frame = lastFrame() ?? '';
75
- expect(frame).toContain('Install update');
76
- expect(frame).toContain('v0.1.10 available');
77
- });
78
- it('keeps the selected update row visible in a constrained Settings pane', () => {
79
- const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
80
- const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
81
- const frame = lastFrame() ?? '';
82
- expect(frame).toContain('> Check for updates');
83
- });
84
- it('keeps Reduce motion visible between ASCII-safe display and update checks', () => {
85
- const settingsIndex = Math.max(0, settingsItems.indexOf('Reduce motion'));
86
- const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
87
- const frame = lastFrame() ?? '';
88
- expect(frame).toContain(' ASCII-safe display');
89
- expect(frame).toContain('> Reduce motion');
90
- expect(frame).toContain(' Check for updates');
91
- });
92
- });
93
- function renderExplore(asciiMode) {
94
- const mode = resolveDisplayMode({ asciiMode }, {});
95
- return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(ExploreScreen, { title: "Explore", subtitle: "Move a map cursor through geotagged stations", stations: [station], selected: 0, loading: false, theme: "green", favorites: new Set(), filterLabel: "", cursor: defaultExploreCursor, pageSize: 8, width: 100, height: 24 }) }));
96
- }
97
- describe('Explore world map rendering', () => {
98
- it('rasterizes land with braille glyphs by default', () => {
99
- const frame = renderExplore(false).lastFrame() ?? '';
100
- expect(/[⠀-⣿]/.test(frame)).toBe(true);
101
- });
102
- it('replaces braille with ASCII in ASCII-safe mode', () => {
103
- const frame = renderExplore(true).lastFrame() ?? '';
104
- expect(/[⠀-⣿]/.test(frame)).toBe(false);
105
- });
106
- });
107
- describe('CountriesScreen rendering', () => {
108
- it('keeps long country rows to one terminal line', () => {
109
- const frame = render(_jsx(CountriesScreen, { countries: [
110
- {
111
- name: 'The Extremely Long Democratic Republic Of The Country With A Very Long Name',
112
- code: 'TL',
113
- stationCount: 123456789
114
- }
115
- ], selected: 0, loading: false, filter: "", editingFilter: false, theme: "green", pageSize: 1, width: 48 })).lastFrame() ?? '';
116
- expect(frame).toContain('>');
117
- expect(frame).toContain('…');
118
- expect(frame).not.toContain('Very Long Name');
119
- });
120
- });
121
- describe('StatsScreen rendering', () => {
122
- it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
123
- const library = {
124
- recent: [],
125
- favorites: [],
126
- imported: [],
127
- trackHistory: [],
128
- searchHistory: [],
129
- activity: {
130
- sessions: [
131
- {
132
- id: 'listen',
133
- station,
134
- startedAt: new Date(2026, 4, 24, 12).toISOString(),
135
- endedAt: new Date(2026, 4, 24, 13).toISOString(),
136
- listenedSeconds: 3600
137
- }
138
- ]
139
- },
140
- settings
141
- };
142
- const frame = render(_jsx(DisplayContext.Provider, { value: resolveDisplayMode({ asciiMode: false }, {}), children: _jsx(StatsScreen, { library: library, theme: "green", width: 132, height: 32 }) })).lastFrame() ?? '';
143
- expect(frame).toContain('Jan');
144
- expect(frame).not.toContain('██');
145
- });
146
- it('starts the contribution graph at January and uses large cells when space allows', () => {
147
- const graph = buildContributionGraph([
148
- { date: '2026-01-01', seconds: 3600 },
149
- { date: '2026-12-31', seconds: 0 }
150
- ], 170);
151
- expect(graph.months.startsWith('Jan')).toBe(true);
152
- expect(graph.months).toContain('Dec');
153
- expect(graph.cellText).toBe(' ');
154
- expect(graph.cellGap).toBe('');
155
- expect(graph.tileHeight).toBe(2);
156
- expect(graph.rows[4]?.cells[0]).toMatchObject({
157
- level: 4,
158
- text: ' ',
159
- visible: true
160
- });
161
- expect(graph.rows[0]?.cells[0]).toMatchObject({
162
- level: 0,
163
- text: ' ',
164
- visible: true
165
- });
166
- expect(graph.rows[6]?.cells.at(-1)).toMatchObject({
167
- level: 0,
168
- text: ' ',
169
- visible: true
170
- });
171
- const normalWidthGraph = buildContributionGraph([
172
- { date: '2026-01-01', seconds: 3600 },
173
- { date: '2026-12-31', seconds: 0 }
174
- ], 128);
175
- expect(normalWidthGraph.cellText).toBe(' ');
176
- expect(normalWidthGraph.cellGap).toBe('');
177
- const compactWidthGraph = buildContributionGraph([
178
- { date: '2026-01-01', seconds: 3600 },
179
- { date: '2026-12-31', seconds: 0 }
180
- ], 80);
181
- expect(compactWidthGraph.cellText).toBe(' ');
182
- expect(compactWidthGraph.cellGap).toBe('');
183
- });
184
- it('uses one-line heatmap cells when the stats panel is height constrained', () => {
185
- const graph = buildContributionGraph([
186
- { date: '2026-01-01', seconds: 3600 },
187
- { date: '2026-12-31', seconds: 0 }
188
- ], 170, 20);
189
- expect(graph.cellText).toBe(' ');
190
- expect(graph.cellGap).toBe('');
191
- expect(graph.tileHeight).toBe(1);
192
- });
193
- it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
194
- const days = [
195
- ...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
196
- { date: '2026-05-20', seconds: 36_000 }
197
- ];
198
- const scaleSeconds = contributionScaleSeconds(days);
199
- expect(scaleSeconds).toBe(3600);
200
- expect(contributionLevel(3600, scaleSeconds)).toBe(4);
201
- expect(contributionLevel(900, scaleSeconds)).toBe(2);
202
- expect(contributionLevel(0, scaleSeconds)).toBe(0);
203
- });
204
- });
205
- describe('HelpScreen rendering', () => {
206
- it('lists keybinding sections and : commands', () => {
207
- const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
208
- const frame = lastFrame() ?? '';
209
- expect(frame).toContain('Navigation');
210
- expect(frame).toContain('Playback');
211
- expect(frame).toContain(':search');
212
- expect(frame).toContain(':doctor');
213
- expect(frame).toContain('Toggle this help');
214
- });
215
- });
216
- describe('NowPlayingScreen rendering', () => {
217
- it('shows recent tracks for the tuned station when diagnostics are open', () => {
218
- const { lastFrame } = renderNowPlaying(false, true);
219
- const frame = lastFrame() ?? '';
220
- expect(frame).toContain('Bjork - Joga');
221
- expect(frame).toContain('UNITED STATES');
222
- });
223
- it('uses Unicode box borders by default', () => {
224
- const { lastFrame } = renderNowPlaying(false, false);
225
- const frame = lastFrame() ?? '';
226
- expect(/[\u2500-\u257f]/.test(frame)).toBe(true);
227
- });
228
- it('emits only ASCII characters in ASCII-safe mode', () => {
229
- const { lastFrame } = renderNowPlaying(true, true);
230
- const frame = lastFrame() ?? '';
231
- expect(frame).toContain('Bjork - Joga');
232
- // No braille, block, box-drawing, or punctuation glyphs survive ASCII mode.
233
- expect(/[^\x00-\x7f]/.test(frame)).toBe(false);
234
- });
235
- });