@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/dist/ui/App.js ADDED
@@ -0,0 +1,770 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
+ import { ProviderManager } from '../providers/provider-manager.js';
5
+ import { PlayerController } from '../player/player-controller.js';
6
+ import { playbackBackendInstallHint } from '../player/backend-install.js';
7
+ import { JsonLibraryStore, stationKey } from '../storage/store.js';
8
+ import { receiverStyleNames } from '../types.js';
9
+ import { appBackground, nextReceiverStyle, nextTheme, panelBackground, textDim, themeAccent } from './theme.js';
10
+ import { homeItems, settingsItems } from './screen-items.js';
11
+ import { AppContent } from './AppContent.js';
12
+ import { TopTabs } from './components/TopTabs.js';
13
+ import { computeTerminalLayout } from './layout.js';
14
+ import { truncate } from './format.js';
15
+ import { playbackFooterText, shouldShowPlaybackFooter } from './playback-footer.js';
16
+ import { pageFooterText } from './page-footer.js';
17
+ import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell } from './terminal-mouse.js';
18
+ import { useAppInput } from './use-app-input.js';
19
+ import { useCommandExecutor } from './use-command-executor.js';
20
+ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
21
+ const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
22
+ const LIVE_RECEIVER_PULSE_MS = 80;
23
+ const AMBIENT_RECEIVER_PULSE_MS = 140;
24
+ const LOADING_SPINNER_MS = 120;
25
+ export function App({ store: providedStore, providers: providedProviders }) {
26
+ const { exit } = useApp();
27
+ const { stdin } = useStdin();
28
+ const { stdout } = useStdout();
29
+ const { columns, rows } = useWindowSize();
30
+ const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
31
+ const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
32
+ const [library, setLibrary] = useState(() => store.snapshot());
33
+ const settingsRef = useRef(library.settings);
34
+ settingsRef.current = library.settings;
35
+ const player = useMemo(() => new PlayerController(() => settingsRef.current), []);
36
+ const [playback, setPlayback] = useState(() => player.getState());
37
+ const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
38
+ const [screen, setScreen] = useState('home');
39
+ const [selected, setSelected] = useState(0);
40
+ const [message, setMessage] = useState(null);
41
+ const [countries, setCountries] = useState([]);
42
+ const [countryFilter, setCountryFilter] = useState('');
43
+ const [editingCountryFilter, setEditingCountryFilter] = useState(false);
44
+ const [loadingCountries, setLoadingCountries] = useState(false);
45
+ const [stationContexts, setStationContexts] = useState(initialStationContexts);
46
+ const [loadingStations, setLoadingStations] = useState(false);
47
+ const [searchQuery, setSearchQuery] = useState('');
48
+ const [editingSearch, setEditingSearch] = useState(true);
49
+ const [playingStation, setPlayingStation] = useState(null);
50
+ const [nowPlaying, setNowPlaying] = useState(null);
51
+ const [location, setLocation] = useState(null);
52
+ const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
53
+ const [providerHealth, setProviderHealth] = useState({});
54
+ const [pulse, setPulse] = useState(0);
55
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
56
+ const [commandMode, setCommandMode] = useState(false);
57
+ const [commandText, setCommandText] = useState('');
58
+ const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
59
+ const [sleepUntil, setSleepUntil] = useState(null);
60
+ const [showDiagnostics, setShowDiagnostics] = useState(false);
61
+ const [capturingTransportAction, setCapturingTransportAction] = useState(null);
62
+ const displayStationsRef = useRef([]);
63
+ const playbackQueueRef = useRef(null);
64
+ const lastRawTransportAtRef = useRef(0);
65
+ const playStationRef = useRef(() => undefined);
66
+ const screenRef = useRef(screen);
67
+ const selectedRef = useRef(selected);
68
+ const selectedByScreenRef = useRef({});
69
+ const stationContextsRef = useRef(stationContexts);
70
+ const lastStationContextKeyRef = useRef('explore');
71
+ const lastSubmittedSearchRef = useRef('');
72
+ const exploreCursorRef = useRef(exploreCursor);
73
+ const exploreRequestRef = useRef(0);
74
+ const exploreMoveTimerRef = useRef(null);
75
+ const theme = library.settings.theme;
76
+ const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
77
+ const diagnostics = player.diagnostics();
78
+ const filterLabel = formatFilterLabel(filters);
79
+ const filteredCountries = useMemo(() => {
80
+ const normalized = countryFilter.toLowerCase().trim();
81
+ if (!normalized) {
82
+ return countries;
83
+ }
84
+ return countries.filter(country => `${country.name} ${country.code}`.toLowerCase().includes(normalized));
85
+ }, [countries, countryFilter]);
86
+ const libraryStations = useMemo(() => buildLibraryStations(library), [library.favorites, library.imported, library.recent]);
87
+ const activeStationContexts = useMemo(() => ({
88
+ ...stationContexts,
89
+ library: {
90
+ title: 'Library',
91
+ subtitle: librarySubtitle(library),
92
+ stations: libraryStations
93
+ }
94
+ }), [library, libraryStations, stationContexts]);
95
+ screenRef.current = screen;
96
+ selectedRef.current = selected;
97
+ stationContextsRef.current = activeStationContexts;
98
+ exploreCursorRef.current = exploreCursor;
99
+ const renderedStationContextKey = stationContextKeyForScreen(screen);
100
+ const activeStationContextKey = renderedStationContextKey ?? lastStationContextKeyRef.current;
101
+ const stationContext = activeStationContexts[activeStationContextKey];
102
+ const stationCounts = useMemo(() => ({
103
+ explore: applyStationFilters(activeStationContexts.explore.stations, filters).length,
104
+ stations: applyStationFilters(activeStationContexts.stations.stations, filters).length,
105
+ search: applyStationFilters(activeStationContexts.search.stations, filters).length,
106
+ nearby: applyStationFilters(activeStationContexts.nearby.stations, filters).length,
107
+ library: applyStationFilters(activeStationContexts.library.stations, filters).length
108
+ }), [activeStationContexts, filters]);
109
+ const itemCountsRef = useRef({
110
+ home: homeItems.length,
111
+ explore: 0,
112
+ countries: 0,
113
+ stations: 0,
114
+ search: 0,
115
+ nearby: 0,
116
+ map: 0,
117
+ 'now-playing': 1,
118
+ library: 0,
119
+ stats: 1,
120
+ settings: settingsItems.length
121
+ });
122
+ itemCountsRef.current = {
123
+ home: homeItems.length,
124
+ explore: stationCounts.explore,
125
+ countries: filteredCountries.length,
126
+ stations: stationCounts.stations,
127
+ search: stationCounts.search,
128
+ nearby: stationCounts.nearby,
129
+ map: filteredCountries.length,
130
+ 'now-playing': 1,
131
+ library: stationCounts.library,
132
+ stats: 1,
133
+ settings: settingsItems.length
134
+ };
135
+ const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
136
+ displayStationsRef.current = displayStations;
137
+ const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
138
+ const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
139
+ const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
140
+ const frameWidth = Math.max(40, layout.columns - 2);
141
+ useEffect(() => player.onChange(setPlayback), [player]);
142
+ useEffect(() => player.onMetadata(setNowPlaying), [player]);
143
+ useEffect(() => {
144
+ if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
145
+ return;
146
+ }
147
+ stdout.write(enableMouseReporting);
148
+ return () => {
149
+ stdout.write(disableMouseReporting);
150
+ };
151
+ }, [layout.compact, screen, stdout]);
152
+ useEffect(() => {
153
+ selectedByScreenRef.current[screen] = selected;
154
+ if (renderedStationContextKey) {
155
+ lastStationContextKeyRef.current = renderedStationContextKey;
156
+ }
157
+ }, [renderedStationContextKey, screen, selected]);
158
+ useEffect(() => {
159
+ if (!shouldAnimateReceiver(screen, playback) ||
160
+ process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
161
+ process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
162
+ return;
163
+ }
164
+ const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
165
+ const timer = setInterval(() => setPulse(value => (value + 1) % 240), intervalMs);
166
+ return () => clearInterval(timer);
167
+ }, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
168
+ useEffect(() => {
169
+ if (playback.state !== 'loading' ||
170
+ process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
171
+ process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
172
+ setSpinnerFrame(0);
173
+ return;
174
+ }
175
+ const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
176
+ return () => clearInterval(timer);
177
+ }, [playback.state]);
178
+ useEffect(() => {
179
+ if ((screen === 'countries' || screen === 'map') && countries.length === 0 && !loadingCountries) {
180
+ setLoadingCountries(true);
181
+ providers
182
+ .countries()
183
+ .then(setCountries)
184
+ .catch(error => setMessage(error instanceof Error ? error.message : 'Could not load countries.'))
185
+ .finally(() => setLoadingCountries(false));
186
+ }
187
+ }, [countries.length, loadingCountries, providers, screen]);
188
+ useEffect(() => () => {
189
+ if (exploreMoveTimerRef.current) {
190
+ clearTimeout(exploreMoveTimerRef.current);
191
+ }
192
+ }, []);
193
+ useEffect(() => {
194
+ setSelected(value => clamp(value, currentItemCount(screen) - 1));
195
+ }, [displayStations.length, filteredCountries.length, screen]);
196
+ useEffect(() => {
197
+ if (!sleepUntil) {
198
+ return;
199
+ }
200
+ const delayMs = sleepUntil - Date.now();
201
+ if (delayMs <= 0) {
202
+ setLibrary(store.finishActiveListeningSession());
203
+ void player.stop();
204
+ setSleepUntil(null);
205
+ return;
206
+ }
207
+ const timer = setTimeout(() => {
208
+ setLibrary(store.finishActiveListeningSession());
209
+ void player.stop();
210
+ setSleepUntil(null);
211
+ }, delayMs);
212
+ return () => clearTimeout(timer);
213
+ }, [player, sleepUntil, store]);
214
+ useEffect(() => {
215
+ const backends = player.refreshDetectedBackends();
216
+ setAvailableBackends(backends);
217
+ if (backends.length === 0) {
218
+ setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
219
+ }
220
+ }, [player]);
221
+ const refreshProviderHealth = useCallback(() => {
222
+ providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
223
+ }, [providers]);
224
+ useEffect(() => {
225
+ refreshProviderHealth();
226
+ }, [refreshProviderHealth]);
227
+ const setStationContextFor = useCallback((key, context) => {
228
+ setStationContexts(current => ({ ...current, [key]: context }));
229
+ }, []);
230
+ const stationMatches = useCallback((left, right) => stationKey(left) === stationKey(right), []);
231
+ const queueContainsStation = useCallback((queue, station) => Boolean(queue?.stations.some(item => stationMatches(item, station))), [stationMatches]);
232
+ const updateSettings = useCallback((settings) => {
233
+ const nextLibrary = store.updateSettings(settings);
234
+ settingsRef.current = nextLibrary.settings;
235
+ setLibrary(nextLibrary);
236
+ return nextLibrary;
237
+ }, [store]);
238
+ const updateMediaKeys = useCallback((mediaKeys) => {
239
+ updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
240
+ }, [updateSettings]);
241
+ const beginLearningTransportKey = useCallback((action) => {
242
+ setCapturingTransportAction(action);
243
+ setMessage(`Press a key for ${mediaActionLabel(action)}. Esc cancels.`);
244
+ }, []);
245
+ const resetLearnedTransportKeys = useCallback(() => {
246
+ updateMediaKeys({ previous: [], playPause: [], next: [] });
247
+ setMessage('Learned media keys reset. Built-in fallbacks still work.');
248
+ }, [updateMediaKeys]);
249
+ const saveLearnedTransportKey = useCallback((action, input) => {
250
+ const mediaKeys = addMediaKeyBinding(settingsRef.current.mediaKeys, action, input);
251
+ updateMediaKeys(mediaKeys);
252
+ setCapturingTransportAction(null);
253
+ setMessage(`Learned ${mediaActionLabel(action)} key.`);
254
+ }, [updateMediaKeys]);
255
+ const go = useCallback((next, options = {}) => {
256
+ selectedByScreenRef.current[screenRef.current] = selectedRef.current;
257
+ const remembered = selectedByScreenRef.current[next] ?? 0;
258
+ const nextSelection = options.resetSelection ? 0 : remembered;
259
+ setScreen(next);
260
+ setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
261
+ if (options.clearMessage !== false) {
262
+ setMessage(null);
263
+ }
264
+ }, []);
265
+ const shutdown = useCallback(() => {
266
+ store.finishActiveListeningSession();
267
+ player.stop().finally(exit);
268
+ }, [exit, player, store]);
269
+ const showStationContext = useCallback((context, next = 'stations', options = {}) => {
270
+ setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
271
+ go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
272
+ }, [go, setStationContextFor]);
273
+ const loadExploreAt = useCallback(async (cursor, options = {}) => {
274
+ const requestId = exploreRequestRef.current + 1;
275
+ exploreRequestRef.current = requestId;
276
+ setLoadingStations(true);
277
+ setMessage(null);
278
+ if (screenRef.current !== 'explore') {
279
+ go('explore', { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
280
+ }
281
+ exploreCursorRef.current = cursor;
282
+ setExploreCursor(cursor);
283
+ const previousExploreStations = stationContextsRef.current.explore.stations;
284
+ setStationContextFor('explore', {
285
+ title: 'Explore world',
286
+ subtitle: `Scanning all geotagged stations near ${formatExploreCursor(cursor)}`,
287
+ stations: previousExploreStations
288
+ });
289
+ try {
290
+ const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
291
+ if (requestId !== exploreRequestRef.current) {
292
+ return;
293
+ }
294
+ setStationContextFor('explore', {
295
+ title: 'Explore world',
296
+ subtitle: formatExploreSubtitle(cursor, stations),
297
+ stations
298
+ });
299
+ selectedByScreenRef.current.explore = 0;
300
+ if (screenRef.current === 'explore') {
301
+ setSelected(0);
302
+ }
303
+ if (stations.length === 0) {
304
+ setMessage(`No geotagged stations found near ${formatExploreCursor(cursor)}.`);
305
+ }
306
+ }
307
+ catch (error) {
308
+ if (requestId !== exploreRequestRef.current) {
309
+ return;
310
+ }
311
+ setMessage(error instanceof Error ? error.message : 'Could not load world stations.');
312
+ }
313
+ finally {
314
+ if (requestId === exploreRequestRef.current) {
315
+ setLoadingStations(false);
316
+ }
317
+ }
318
+ }, [go, providers, setStationContextFor]);
319
+ const loadExplore = useCallback(async () => {
320
+ await loadExploreAt(exploreCursorRef.current, { resetSelection: true });
321
+ }, [loadExploreAt]);
322
+ const moveExploreMapCursor = useCallback((direction, fast = false) => {
323
+ const next = shiftExploreCursor(exploreCursorRef.current, direction, fast);
324
+ exploreCursorRef.current = next;
325
+ setExploreCursor(next);
326
+ setSelected(0);
327
+ setLoadingStations(true);
328
+ setStationContextFor('explore', {
329
+ title: 'Explore world',
330
+ subtitle: `Move cursor: ${formatExploreCursor(next)}`,
331
+ stations: stationContextsRef.current.explore.stations
332
+ });
333
+ if (exploreMoveTimerRef.current) {
334
+ clearTimeout(exploreMoveTimerRef.current);
335
+ }
336
+ exploreMoveTimerRef.current = setTimeout(() => {
337
+ void loadExploreAt(next, { resetSelection: true, clearMessage: false });
338
+ }, 220);
339
+ }, [loadExploreAt, setStationContextFor]);
340
+ const moveExploreMapCursorToCell = useCallback((x, y) => {
341
+ const next = exploreCursorForMouseCell(x, y, frameWidth, layout);
342
+ if (!next) {
343
+ return;
344
+ }
345
+ if (exploreMoveTimerRef.current) {
346
+ clearTimeout(exploreMoveTimerRef.current);
347
+ }
348
+ void loadExploreAt(next, { resetSelection: true, clearMessage: false });
349
+ }, [frameWidth, layout, loadExploreAt]);
350
+ const loadCountry = useCallback(async (country) => {
351
+ setLoadingStations(true);
352
+ setMessage(null);
353
+ try {
354
+ const stations = await providers.byCountry(country.code, 120);
355
+ showStationContext({
356
+ title: country.name,
357
+ subtitle: `${country.code} · ${country.stationCount.toLocaleString()} listed stations`,
358
+ stations
359
+ }, 'stations');
360
+ }
361
+ catch (error) {
362
+ setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
363
+ }
364
+ finally {
365
+ setLoadingStations(false);
366
+ }
367
+ }, [providers, showStationContext]);
368
+ const runSearch = useCallback(async (query = searchQuery) => {
369
+ if (!query.trim()) {
370
+ setMessage('Enter a station, genre, language, or place.');
371
+ return;
372
+ }
373
+ setLoadingStations(true);
374
+ setMessage(null);
375
+ try {
376
+ const stations = await providers.search(query, settingsRef.current, {
377
+ limit: 90,
378
+ codec: filters.codec ?? undefined,
379
+ language: filters.language ?? undefined,
380
+ minBitrate: filters.minBitrate ?? undefined
381
+ });
382
+ setStationContextFor('search', {
383
+ title: `Search: ${query}`,
384
+ subtitle: 'Matches across enabled public station directories',
385
+ stations
386
+ });
387
+ selectedByScreenRef.current.search = 0;
388
+ lastSubmittedSearchRef.current = query.trim();
389
+ setSelected(0);
390
+ setEditingSearch(true);
391
+ }
392
+ catch (error) {
393
+ setMessage(error instanceof Error ? error.message : 'Search failed.');
394
+ }
395
+ finally {
396
+ setLoadingStations(false);
397
+ }
398
+ }, [filters, providers, searchQuery, setStationContextFor]);
399
+ const loadNearby = useCallback(async () => {
400
+ setLoadingStations(true);
401
+ setMessage(null);
402
+ go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
403
+ try {
404
+ if (!settingsRef.current.enableNearbyLocation) {
405
+ setStationContextFor('nearby', {
406
+ title: 'Nearby',
407
+ subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
408
+ stations: []
409
+ });
410
+ return;
411
+ }
412
+ const detected = location ?? (await providers.detectLocation());
413
+ setLocation(detected);
414
+ if (!detected) {
415
+ setStationContextFor('nearby', {
416
+ title: 'Nearby',
417
+ subtitle: 'Location detection was unavailable',
418
+ stations: []
419
+ });
420
+ return;
421
+ }
422
+ const stations = await providers.nearby(detected, 90);
423
+ setStationContextFor('nearby', {
424
+ title: 'Nearby',
425
+ subtitle: `${[detected.city, detected.region, detected.country].filter(Boolean).join(', ')} · ${detected.source}`,
426
+ stations
427
+ });
428
+ }
429
+ catch (error) {
430
+ setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
431
+ }
432
+ finally {
433
+ setLoadingStations(false);
434
+ }
435
+ }, [go, location, providers, setStationContextFor]);
436
+ const queueFromCurrentList = useCallback((station) => {
437
+ const sourceScreen = screenRef.current;
438
+ const sourceContextKey = stationContextKeyForScreen(sourceScreen);
439
+ const currentList = displayStationsRef.current;
440
+ if (currentList.some(item => stationMatches(item, station))) {
441
+ return {
442
+ title: sourceContextKey ? stationContextsRef.current[sourceContextKey].title : 'Current station list',
443
+ sourceScreen,
444
+ sourceContextKey,
445
+ stations: currentList
446
+ };
447
+ }
448
+ if (queueContainsStation(playbackQueueRef.current, station)) {
449
+ return playbackQueueRef.current;
450
+ }
451
+ return {
452
+ title: station.name,
453
+ sourceScreen,
454
+ sourceContextKey: null,
455
+ stations: [station]
456
+ };
457
+ }, [queueContainsStation, stationMatches]);
458
+ const rememberQueueSelection = useCallback((queue, index) => {
459
+ if (queue.sourceContextKey) {
460
+ selectedByScreenRef.current[queue.sourceScreen] = index;
461
+ }
462
+ if (screenRef.current === queue.sourceScreen) {
463
+ setSelected(index);
464
+ }
465
+ }, []);
466
+ const playStation = useCallback(async (station, options = {}) => {
467
+ const queue = options.queue ?? queueFromCurrentList(station);
468
+ setMessage(`Tuning ${station.name}...`);
469
+ setNowPlaying(null);
470
+ try {
471
+ const resolved = await providers.resolve(station);
472
+ await player.play(station, resolved.url);
473
+ setPlayingStation(station);
474
+ playbackQueueRef.current = queue;
475
+ store.startListeningSession(station);
476
+ setLibrary(store.addRecent(station));
477
+ if (options.openNowPlaying) {
478
+ go('now-playing');
479
+ }
480
+ setMessage(null);
481
+ }
482
+ catch (error) {
483
+ const message = error instanceof Error ? error.message : 'Could not tune station.';
484
+ const currentList = queue.stations;
485
+ const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
486
+ const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
487
+ if (settingsRef.current.skipBrokenStreams && nextStation) {
488
+ setMessage(`${message} Skipping to ${nextStation.name}.`);
489
+ rememberQueueSelection(queue, currentIndex + 1);
490
+ setTimeout(() => playStationRef.current(nextStation, { ...options, queue }), 250);
491
+ return;
492
+ }
493
+ setMessage(message);
494
+ }
495
+ }, [go, player, providers, queueFromCurrentList, rememberQueueSelection, store]);
496
+ playStationRef.current = playStation;
497
+ const toggleFavorite = useCallback((station) => {
498
+ if (!station) {
499
+ setMessage('Select or play a station before pressing f.');
500
+ return;
501
+ }
502
+ const wasFavorite = store.isFavorite(station);
503
+ setLibrary(store.toggleFavorite(station));
504
+ setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
505
+ }, [store]);
506
+ const setVolume = useCallback((volume) => {
507
+ const clamped = clampVolume(volume);
508
+ updateSettings({ volume: clamped });
509
+ void player.setVolume(clamped);
510
+ }, [player, updateSettings]);
511
+ const adjustVolume = useCallback((delta) => {
512
+ setVolume((player.getState().volume || library.settings.volume) + delta);
513
+ }, [library.settings.volume, player, setVolume]);
514
+ const toggleMute = useCallback(() => {
515
+ void player.toggleMute();
516
+ }, [player]);
517
+ const cycleDisplayColor = useCallback(() => {
518
+ const theme = nextTheme(settingsRef.current.theme);
519
+ updateSettings({ theme });
520
+ setMessage(`Display color: ${theme}`);
521
+ }, [updateSettings]);
522
+ const cycleReceiverStyle = useCallback(() => {
523
+ const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
524
+ updateSettings({ receiverStyle });
525
+ setMessage(`Receiver style: ${receiverStyle}`);
526
+ }, [updateSettings]);
527
+ const toggleRadioGarden = useCallback(() => {
528
+ const enableRadioGarden = !settingsRef.current.enableRadioGarden;
529
+ updateSettings({ enableRadioGarden });
530
+ setMessage(`Radio Garden ${enableRadioGarden ? 'enabled' : 'disabled'}.`);
531
+ setTimeout(refreshProviderHealth, 0);
532
+ }, [refreshProviderHealth, updateSettings]);
533
+ const toggleNearbyLocation = useCallback(() => {
534
+ const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
535
+ updateSettings({ enableNearbyLocation });
536
+ setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
537
+ }, [updateSettings]);
538
+ const cyclePlaybackBackend = useCallback(() => {
539
+ const current = settingsRef.current.preferredBackend;
540
+ const preferredBackend = current === 'auto' ? 'mpv' : current === 'mpv' ? 'ffplay' : 'auto';
541
+ updateSettings({ preferredBackend });
542
+ setMessage(`Playback backend: ${preferredBackend}`);
543
+ }, [updateSettings]);
544
+ const toggleSkipBrokenStreams = useCallback(() => {
545
+ const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
546
+ updateSettings({ skipBrokenStreams });
547
+ setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
548
+ }, [updateSettings]);
549
+ const cycleSleepTimer = useCallback(() => {
550
+ const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
551
+ const next = nextSleepTimerMinutes(currentMinutes);
552
+ setSleepUntil(next ? Date.now() + next * 60_000 : null);
553
+ }, [sleepUntil]);
554
+ const openLibrary = useCallback(() => {
555
+ go('library', { resetSelection: false });
556
+ }, [go]);
557
+ const selectedStation = displayStations[selected] ?? null;
558
+ const openScreen = useCallback((target) => {
559
+ if (target === 'explore') {
560
+ if (stationContextsRef.current.explore.stations.length > 0) {
561
+ go('explore');
562
+ }
563
+ else {
564
+ void loadExplore();
565
+ }
566
+ }
567
+ else if (target === 'nearby') {
568
+ if (stationContextsRef.current.nearby.stations.length > 0) {
569
+ go('nearby');
570
+ }
571
+ else {
572
+ void loadNearby();
573
+ }
574
+ }
575
+ else if (target === 'search') {
576
+ go('search');
577
+ setEditingSearch(true);
578
+ }
579
+ else if (target === 'library') {
580
+ openLibrary();
581
+ }
582
+ else {
583
+ go(target);
584
+ }
585
+ }, [go, loadExplore, loadNearby, openLibrary]);
586
+ const openAdjacentTab = useCallback((direction) => {
587
+ const active = activeTabForScreen(screen);
588
+ const currentIndex = topTabs.findIndex(tab => tab.screen === active);
589
+ const nextIndex = (currentIndex + direction + topTabs.length) % topTabs.length;
590
+ const next = topTabs[nextIndex];
591
+ if (next) {
592
+ openScreen(next.screen);
593
+ }
594
+ }, [openScreen, screen]);
595
+ const executeCommand = useCommandExecutor({
596
+ beginLearningTransportKey,
597
+ countries,
598
+ go,
599
+ loadCountry,
600
+ openLibrary,
601
+ player,
602
+ playingStation,
603
+ providers,
604
+ resetLearnedTransportKeys,
605
+ runSearch,
606
+ screen,
607
+ selectedStation,
608
+ setCountries,
609
+ setFilters,
610
+ setLibrary,
611
+ setMessage,
612
+ setSearchQuery,
613
+ setSleepUntil,
614
+ setVolume,
615
+ settingsRef,
616
+ store,
617
+ toggleFavorite,
618
+ toggleMute,
619
+ updateSettings
620
+ });
621
+ const playAdjacent = useCallback((direction) => {
622
+ if (!playingStation) {
623
+ setMessage('Tune a station from a list before using previous/next.');
624
+ return;
625
+ }
626
+ const queue = queueContainsStation(playbackQueueRef.current, playingStation)
627
+ ? playbackQueueRef.current
628
+ : queueFromCurrentList(playingStation);
629
+ if (queue.stations.length <= 1) {
630
+ setMessage('No adjacent stations in the current source list.');
631
+ return;
632
+ }
633
+ const currentKey = stationKey(playingStation);
634
+ const currentIndex = queue.stations.findIndex(station => stationKey(station) === currentKey);
635
+ const nextIndex = currentIndex === -1
636
+ ? 0
637
+ : (currentIndex + direction + queue.stations.length) % queue.stations.length;
638
+ rememberQueueSelection(queue, nextIndex);
639
+ const nextStation = queue.stations[nextIndex];
640
+ if (nextStation) {
641
+ void playStation(nextStation, { queue });
642
+ }
643
+ }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
644
+ useAppInput({
645
+ adjustVolume,
646
+ beginLearningTransportKey,
647
+ capturingTransportAction,
648
+ commandMode,
649
+ commandText,
650
+ currentItemCount,
651
+ cycleDisplayColor,
652
+ cyclePlaybackBackend,
653
+ cycleReceiverStyle,
654
+ cycleSleepTimer,
655
+ editingCountryFilter,
656
+ editingSearch,
657
+ executeCommand,
658
+ filteredCountries,
659
+ go,
660
+ lastRawTransportAtRef,
661
+ lastSubmittedSearchRef,
662
+ loadCountry,
663
+ openAdjacentTab,
664
+ openScreen,
665
+ playAdjacent,
666
+ playStation,
667
+ player,
668
+ playingStation,
669
+ moveExploreCursor: moveExploreMapCursor,
670
+ moveExploreCursorToCell: moveExploreMapCursorToCell,
671
+ refreshProviderHealth,
672
+ resetLearnedTransportKeys,
673
+ runSearch,
674
+ saveLearnedTransportKey,
675
+ screen,
676
+ searchQuery,
677
+ selected,
678
+ selectedStation,
679
+ setCapturingTransportAction,
680
+ setCommandMode,
681
+ setCommandText,
682
+ setCountryFilter,
683
+ setEditingCountryFilter,
684
+ setEditingSearch,
685
+ setMessage,
686
+ setSearchQuery,
687
+ setSelected,
688
+ setShowDiagnostics,
689
+ settingsRef,
690
+ shutdown,
691
+ stdin,
692
+ toggleFavorite,
693
+ toggleMute,
694
+ toggleNearbyLocation,
695
+ toggleRadioGarden,
696
+ toggleSkipBrokenStreams
697
+ });
698
+ function currentItemCount(currentScreen) {
699
+ return itemCountsRef.current[currentScreen] ?? 0;
700
+ }
701
+ const hasTopTabs = !layout.compact;
702
+ const globalFooter = '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
703
+ const playbackFooter = playbackFooterText({
704
+ station: playingStation,
705
+ playback,
706
+ metadata: nowPlaying,
707
+ queue: playbackQueueRef.current,
708
+ favorite: store.isFavorite(playingStation),
709
+ sleepLabel,
710
+ width: frameWidth,
711
+ spinnerFrame
712
+ });
713
+ const pageFooter = pageFooterText({
714
+ capturingTransportAction,
715
+ commandMode,
716
+ commandText,
717
+ editingCountryFilter,
718
+ editingSearch,
719
+ screen
720
+ });
721
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playback.backend || 'no backend'} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: appBackground, children: [_jsx(AppContent, { backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : null] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: panelBackground, children: [playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }));
722
+ }
723
+ function buildLibraryStations(library) {
724
+ const stations = [];
725
+ const seen = new Set();
726
+ const addStation = (station) => {
727
+ const key = stationKey(station);
728
+ if (seen.has(key)) {
729
+ return;
730
+ }
731
+ seen.add(key);
732
+ stations.push(station);
733
+ };
734
+ for (const station of library.favorites) {
735
+ addStation(station);
736
+ }
737
+ for (const item of library.recent) {
738
+ addStation(item.station);
739
+ }
740
+ for (const station of library.imported) {
741
+ addStation(station);
742
+ }
743
+ return stations;
744
+ }
745
+ function exploreCursorLocation(cursor) {
746
+ return {
747
+ latitude: cursor.latitude,
748
+ longitude: cursor.longitude,
749
+ source: 'explore cursor'
750
+ };
751
+ }
752
+ function formatExploreSubtitle(cursor, stations) {
753
+ if (stations.length === 0) {
754
+ return `No geotagged stations near ${formatExploreCursor(cursor)}`;
755
+ }
756
+ const farthest = stations.reduce((max, station) => Math.max(max, station.distanceKm ?? 0), 0);
757
+ return `${stations.length} nearest to ${formatExploreCursor(cursor)} · within ${formatDistanceKm(farthest)}`;
758
+ }
759
+ function formatDistanceKm(distanceKm) {
760
+ if (distanceKm < 1) {
761
+ return `${Math.round(distanceKm * 1000)} m`;
762
+ }
763
+ if (distanceKm < 100) {
764
+ return `${distanceKm.toFixed(1)} km`;
765
+ }
766
+ return `${Math.round(distanceKm).toLocaleString()} km`;
767
+ }
768
+ function librarySubtitle(library) {
769
+ return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
770
+ }