@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
@@ -0,0 +1,387 @@
1
+ import { useEffect } from 'react';
2
+ import { useInput } from 'ink';
3
+ import { homeItems, settingsItems } from './screen-items.js';
4
+ import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput } from './app-state.js';
5
+ import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
6
+ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, setCapturingTransportAction, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
+ useEffect(() => {
8
+ const onData = (data) => {
9
+ const rawInput = String(data);
10
+ if (capturingTransportAction) {
11
+ if (rawInput === '\u001B') {
12
+ setCapturingTransportAction(null);
13
+ setMessage('Media key learning canceled.');
14
+ return;
15
+ }
16
+ if (rawInput === '\u0003' || rawInput.length === 0) {
17
+ return;
18
+ }
19
+ saveLearnedTransportKey(capturingTransportAction, rawInput);
20
+ return;
21
+ }
22
+ const mouseEvents = parseSgrMouseEvents(rawInput);
23
+ if (mouseEvents.length > 0) {
24
+ const click = primaryMousePress(mouseEvents);
25
+ lastRawTransportAtRef.current = Date.now();
26
+ if (!commandMode && screen === 'explore' && click) {
27
+ moveExploreCursorToCell(click.x, click.y);
28
+ }
29
+ return;
30
+ }
31
+ const action = mediaTransportActionForInput(rawInput, settingsRef.current.mediaKeys);
32
+ if (isPlainPrintableInput(rawInput) &&
33
+ (commandMode || (screen === 'search' && editingSearch) || ((screen === 'countries' || screen === 'map') && editingCountryFilter))) {
34
+ return;
35
+ }
36
+ if (action === 'previous') {
37
+ lastRawTransportAtRef.current = Date.now();
38
+ playAdjacent(-1);
39
+ }
40
+ else if (action === 'next') {
41
+ lastRawTransportAtRef.current = Date.now();
42
+ playAdjacent(1);
43
+ }
44
+ else if (action === 'playPause') {
45
+ lastRawTransportAtRef.current = Date.now();
46
+ void player.togglePause();
47
+ }
48
+ };
49
+ stdin.on('data', onData);
50
+ return () => {
51
+ stdin.off('data', onData);
52
+ };
53
+ }, [
54
+ capturingTransportAction,
55
+ commandMode,
56
+ editingCountryFilter,
57
+ editingSearch,
58
+ lastRawTransportAtRef,
59
+ playAdjacent,
60
+ player,
61
+ moveExploreCursorToCell,
62
+ saveLearnedTransportKey,
63
+ screen,
64
+ setCapturingTransportAction,
65
+ setMessage,
66
+ settingsRef,
67
+ stdin
68
+ ]);
69
+ useInput((input, key) => {
70
+ if (key.ctrl && input === 'c') {
71
+ shutdown();
72
+ return;
73
+ }
74
+ if (capturingTransportAction) {
75
+ return;
76
+ }
77
+ if (Date.now() - lastRawTransportAtRef.current < 50) {
78
+ return;
79
+ }
80
+ if (commandMode) {
81
+ if (key.return) {
82
+ void executeCommand(commandText);
83
+ setCommandText('');
84
+ setCommandMode(false);
85
+ return;
86
+ }
87
+ if (key.escape) {
88
+ setCommandText('');
89
+ setCommandMode(false);
90
+ return;
91
+ }
92
+ if (isEditableInput(input, key)) {
93
+ setCommandText(value => applyTextInput(value, input, key));
94
+ }
95
+ return;
96
+ }
97
+ if (key.shift && key.leftArrow) {
98
+ playAdjacent(-1);
99
+ return;
100
+ }
101
+ if (key.shift && key.rightArrow) {
102
+ playAdjacent(1);
103
+ return;
104
+ }
105
+ if (key.tab) {
106
+ openAdjacentTab(key.shift ? -1 : 1);
107
+ return;
108
+ }
109
+ if (key.rightArrow) {
110
+ openAdjacentTab(1);
111
+ return;
112
+ }
113
+ if (key.leftArrow) {
114
+ openAdjacentTab(-1);
115
+ return;
116
+ }
117
+ if (screen === 'search' && editingSearch) {
118
+ if (key.return) {
119
+ if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && selectedStation) {
120
+ void playStation(selectedStation);
121
+ }
122
+ else {
123
+ void runSearch();
124
+ }
125
+ return;
126
+ }
127
+ if (key.escape) {
128
+ setEditingSearch(false);
129
+ return;
130
+ }
131
+ if (isEditableInput(input, key)) {
132
+ setSearchQuery(value => applyTextInput(value, input, key));
133
+ setEditingSearch(true);
134
+ return;
135
+ }
136
+ }
137
+ if ((screen === 'countries' || screen === 'map') && editingCountryFilter) {
138
+ if (key.return || key.escape) {
139
+ setEditingCountryFilter(false);
140
+ setSelected(0);
141
+ return;
142
+ }
143
+ if (isEditableInput(input, key)) {
144
+ setCountryFilter(value => applyTextInput(value, input, key));
145
+ }
146
+ return;
147
+ }
148
+ if (screen === 'explore') {
149
+ const exploreMove = exploreMoveForInput(input);
150
+ if (exploreMove) {
151
+ moveExploreCursor(exploreMove.direction, exploreMove.fast);
152
+ return;
153
+ }
154
+ }
155
+ if (input === 'q') {
156
+ shutdown();
157
+ return;
158
+ }
159
+ if (input.startsWith(':')) {
160
+ const seed = input.slice(1).replace(/[\r\n]+$/g, '');
161
+ if (/[\r\n]/.test(input)) {
162
+ void executeCommand(seed);
163
+ }
164
+ else {
165
+ setCommandMode(true);
166
+ setCommandText(seed);
167
+ }
168
+ return;
169
+ }
170
+ if (input === '+' || input === '=') {
171
+ adjustVolume(5);
172
+ return;
173
+ }
174
+ if (input === '-') {
175
+ adjustVolume(-5);
176
+ return;
177
+ }
178
+ if (input === 'm') {
179
+ toggleMute();
180
+ return;
181
+ }
182
+ if (input === ',' || input === '<') {
183
+ playAdjacent(-1);
184
+ return;
185
+ }
186
+ if (input === '.' || input === '>') {
187
+ playAdjacent(1);
188
+ return;
189
+ }
190
+ if (input === 't') {
191
+ cycleDisplayColor();
192
+ return;
193
+ }
194
+ if (input === 'v') {
195
+ cycleReceiverStyle();
196
+ return;
197
+ }
198
+ if (input === 'o') {
199
+ cyclePlaybackBackend();
200
+ return;
201
+ }
202
+ if (input === 'g') {
203
+ toggleRadioGarden();
204
+ return;
205
+ }
206
+ if (input === 'l') {
207
+ toggleNearbyLocation();
208
+ return;
209
+ }
210
+ if (input === 'x') {
211
+ toggleSkipBrokenStreams();
212
+ return;
213
+ }
214
+ if (input === 'r') {
215
+ refreshProviderHealth();
216
+ setMessage('Provider health refreshed.');
217
+ return;
218
+ }
219
+ if (input === 's' && screen === 'now-playing') {
220
+ cycleSleepTimer();
221
+ return;
222
+ }
223
+ if (input === 'd' && screen === 'now-playing') {
224
+ setShowDiagnostics(value => !value);
225
+ return;
226
+ }
227
+ if (input === ']') {
228
+ setSelected(value => clamp(value + 10, currentItemCount(screen) - 1));
229
+ return;
230
+ }
231
+ if (input === '[') {
232
+ setSelected(value => clamp(value - 10, currentItemCount(screen) - 1));
233
+ return;
234
+ }
235
+ if (screen === 'home' && (/^[1-9]$/.test(input) || input === '0')) {
236
+ const menuIndex = input === '0' ? 9 : Number(input) - 1;
237
+ setSelected(menuIndex);
238
+ const target = homeItems[menuIndex]?.screen;
239
+ if (target) {
240
+ openScreen(target);
241
+ }
242
+ return;
243
+ }
244
+ if (input === 'b' || key.escape) {
245
+ go('home');
246
+ return;
247
+ }
248
+ if (input === '/') {
249
+ if (screen === 'search') {
250
+ setEditingSearch(true);
251
+ }
252
+ if (screen === 'countries' || screen === 'map') {
253
+ setEditingCountryFilter(true);
254
+ }
255
+ return;
256
+ }
257
+ if (input === 'w' && screen === 'countries') {
258
+ go('map');
259
+ return;
260
+ }
261
+ if (input === 'w' && screen === 'map') {
262
+ go('countries');
263
+ return;
264
+ }
265
+ if (input === 'f') {
266
+ toggleFavorite(favoriteTarget(screen, selectedStation, playingStation));
267
+ return;
268
+ }
269
+ if (input === ' ') {
270
+ void player.togglePause();
271
+ return;
272
+ }
273
+ if (input === 'n' && screen === 'now-playing') {
274
+ playAdjacent(1);
275
+ return;
276
+ }
277
+ if (input === 'p' && screen === 'now-playing') {
278
+ playAdjacent(-1);
279
+ return;
280
+ }
281
+ if (input === 'n') {
282
+ setSelected(value => clamp(value + 1, currentItemCount(screen) - 1));
283
+ return;
284
+ }
285
+ if (input === 'p') {
286
+ setSelected(value => clamp(value - 1, currentItemCount(screen) - 1));
287
+ return;
288
+ }
289
+ if (key.downArrow) {
290
+ setSelected(value => clamp(value + 1, currentItemCount(screen) - 1));
291
+ return;
292
+ }
293
+ if (key.upArrow) {
294
+ setSelected(value => clamp(value - 1, currentItemCount(screen) - 1));
295
+ return;
296
+ }
297
+ if (key.return) {
298
+ if (screen === 'home') {
299
+ const target = homeItems[selected]?.screen;
300
+ if (target) {
301
+ openScreen(target);
302
+ }
303
+ return;
304
+ }
305
+ if (screen === 'countries') {
306
+ const country = filteredCountries[selected];
307
+ if (country) {
308
+ void loadCountry(country);
309
+ }
310
+ return;
311
+ }
312
+ if (screen === 'settings') {
313
+ const item = settingsItems[selected];
314
+ if (item === 'Cycle display color') {
315
+ cycleDisplayColor();
316
+ }
317
+ else if (item === 'Toggle Radio Garden experimental adapter') {
318
+ toggleRadioGarden();
319
+ }
320
+ else if (item === 'Cycle receiver style') {
321
+ cycleReceiverStyle();
322
+ }
323
+ else if (item === 'Toggle nearby location lookup') {
324
+ toggleNearbyLocation();
325
+ }
326
+ else if (item === 'Cycle playback backend') {
327
+ cyclePlaybackBackend();
328
+ }
329
+ else if (item === 'Volume up') {
330
+ adjustVolume(5);
331
+ }
332
+ else if (item === 'Volume down') {
333
+ adjustVolume(-5);
334
+ }
335
+ else if (item === 'Mute or unmute') {
336
+ toggleMute();
337
+ }
338
+ else if (item === 'Toggle skip broken streams') {
339
+ toggleSkipBrokenStreams();
340
+ }
341
+ else if (item === 'Refresh provider health') {
342
+ refreshProviderHealth();
343
+ setMessage('Provider health refreshed.');
344
+ }
345
+ else if (item === 'Learn previous media key') {
346
+ beginLearningTransportKey('previous');
347
+ }
348
+ else if (item === 'Learn play/pause media key') {
349
+ beginLearningTransportKey('playPause');
350
+ }
351
+ else if (item === 'Learn next media key') {
352
+ beginLearningTransportKey('next');
353
+ }
354
+ else if (item === 'Reset learned media keys') {
355
+ resetLearnedTransportKeys();
356
+ }
357
+ return;
358
+ }
359
+ if (screen === 'map') {
360
+ const country = filteredCountries[selected];
361
+ if (country) {
362
+ void loadCountry(country);
363
+ }
364
+ return;
365
+ }
366
+ if (selectedStation) {
367
+ void playStation(selectedStation);
368
+ }
369
+ }
370
+ });
371
+ }
372
+ function exploreMoveForInput(input) {
373
+ const normalized = input.toLowerCase();
374
+ if (normalized === 'w') {
375
+ return { direction: 'up', fast: input === 'W' };
376
+ }
377
+ if (normalized === 's') {
378
+ return { direction: 'down', fast: input === 'S' };
379
+ }
380
+ if (normalized === 'a') {
381
+ return { direction: 'left', fast: input === 'A' };
382
+ }
383
+ if (normalized === 'd') {
384
+ return { direction: 'right', fast: input === 'D' };
385
+ }
386
+ return null;
387
+ }
@@ -0,0 +1,156 @@
1
+ import { useCallback } from 'react';
2
+ import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
+ export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
4
+ return useCallback(async (rawCommand) => {
5
+ const trimmed = rawCommand.trim();
6
+ if (!trimmed) {
7
+ return;
8
+ }
9
+ const [name = '', ...rest] = trimmed.split(/\s+/);
10
+ const value = rest.join(' ');
11
+ if (name === 'search' || name === 's') {
12
+ setSearchQuery(value);
13
+ go('search');
14
+ await runSearch(value);
15
+ return;
16
+ }
17
+ if (name === 'country' || name === 'c') {
18
+ if (!value.trim()) {
19
+ setMessage('Usage: :country <name or code>');
20
+ return;
21
+ }
22
+ const availableCountries = countries.length > 0 ? countries : await providers.countries();
23
+ if (countries.length === 0) {
24
+ setCountries(availableCountries);
25
+ }
26
+ const match = availableCountries.find(country => `${country.code} ${country.name}`.toLowerCase().includes(value.toLowerCase()));
27
+ if (!match) {
28
+ setMessage(`Country not found: ${value}`);
29
+ return;
30
+ }
31
+ await loadCountry(match);
32
+ return;
33
+ }
34
+ if (name === 'codec') {
35
+ setFilters(current => ({ ...current, codec: value && value !== 'any' ? value.toUpperCase() : null }));
36
+ return;
37
+ }
38
+ if (name === 'language' || name === 'lang') {
39
+ setFilters(current => ({ ...current, language: value && value !== 'any' ? value : null }));
40
+ return;
41
+ }
42
+ if (name === 'bitrate') {
43
+ const bitrate = Number(value);
44
+ setFilters(current => ({ ...current, minBitrate: Number.isFinite(bitrate) && bitrate > 0 ? bitrate : null }));
45
+ return;
46
+ }
47
+ if (name === 'clear') {
48
+ setFilters({ codec: null, language: null, minBitrate: null });
49
+ setMessage('Filters cleared.');
50
+ return;
51
+ }
52
+ if (name === 'vol' || name === 'volume') {
53
+ const volume = Number(value);
54
+ if (Number.isFinite(volume)) {
55
+ setVolume(volume);
56
+ }
57
+ return;
58
+ }
59
+ if (name === 'mute') {
60
+ toggleMute();
61
+ return;
62
+ }
63
+ if (name === 'location') {
64
+ const enabled = value === 'on' || value === 'true' || value === '1';
65
+ updateSettings({ enableNearbyLocation: enabled });
66
+ setMessage(`Nearby location lookup ${enabled ? 'enabled' : 'disabled'}.`);
67
+ return;
68
+ }
69
+ if (name === 'timeout') {
70
+ const seconds = Number(value);
71
+ if (Number.isFinite(seconds)) {
72
+ updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
73
+ }
74
+ return;
75
+ }
76
+ if (name === 'skip') {
77
+ const enabled = value !== 'off' && value !== 'false' && value !== '0';
78
+ updateSettings({ skipBrokenStreams: enabled });
79
+ return;
80
+ }
81
+ if (name === 'learn' || name === 'bind' || name === 'key') {
82
+ const action = parseMediaActionName(value);
83
+ if (!action) {
84
+ setMessage('Usage: :learn previous, :learn play, or :learn next');
85
+ return;
86
+ }
87
+ beginLearningTransportKey(action);
88
+ return;
89
+ }
90
+ if (name === 'keys') {
91
+ if (value === 'reset' || value === 'clear') {
92
+ resetLearnedTransportKeys();
93
+ return;
94
+ }
95
+ const mediaKeys = normalizeMediaKeyBindings(settingsRef.current.mediaKeys);
96
+ setMessage(`Learned keys: prev ${mediaKeys.previous.length}, play ${mediaKeys.playPause.length}, next ${mediaKeys.next.length}. Use :keys reset to clear.`);
97
+ return;
98
+ }
99
+ if (name === 'sleep') {
100
+ const minutes = Number(value);
101
+ setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);
102
+ return;
103
+ }
104
+ if (name === 'map') {
105
+ go('map');
106
+ return;
107
+ }
108
+ if (name === 'stats') {
109
+ go('stats');
110
+ return;
111
+ }
112
+ if (name === 'library' || name === 'recent' || name === 'favorites' || name === 'imports') {
113
+ openLibrary();
114
+ return;
115
+ }
116
+ if (name === 'favorite' || name === 'fav') {
117
+ toggleFavorite(favoriteTarget(screen, selectedStation, playingStation));
118
+ return;
119
+ }
120
+ if (name === 'settings') {
121
+ go('settings');
122
+ return;
123
+ }
124
+ if (name === 'stop') {
125
+ setLibrary(store.finishActiveListeningSession());
126
+ await player.stop();
127
+ return;
128
+ }
129
+ setMessage(`Unknown command: ${name}`);
130
+ }, [
131
+ beginLearningTransportKey,
132
+ countries,
133
+ go,
134
+ loadCountry,
135
+ openLibrary,
136
+ player,
137
+ playingStation,
138
+ providers,
139
+ resetLearnedTransportKeys,
140
+ runSearch,
141
+ screen,
142
+ selectedStation,
143
+ setCountries,
144
+ setFilters,
145
+ setLibrary,
146
+ setMessage,
147
+ setSearchQuery,
148
+ setSleepUntil,
149
+ setVolume,
150
+ settingsRef,
151
+ store,
152
+ toggleFavorite,
153
+ toggleMute,
154
+ updateSettings
155
+ ]);
156
+ }