@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
@@ -4,29 +4,67 @@ export const homeItems = [
4
4
  { screen: 'explore', label: 'Explore', detail: 'Move a map cursor through geotagged stations' },
5
5
  { screen: 'search', label: 'Search', detail: 'Find stations by name, genre, language, place' },
6
6
  { screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
7
- { screen: 'nearby', label: 'Nearby', detail: 'Opt-in approximate location for local stations' },
7
+ { screen: 'nearby', label: 'Nearby', detail: 'Approximate IP location for local stations' },
8
8
  { screen: 'stats', label: 'Stats', detail: 'Listening graph, stations, streaks, hours' },
9
9
  { screen: 'settings', label: 'Settings', detail: 'Audio output, colors, providers' }
10
10
  ];
11
- export const settingsItems = [
12
- 'Cycle display color',
13
- 'Cycle receiver style',
14
- 'Toggle Radio Garden experimental adapter',
15
- 'Toggle nearby location lookup',
16
- 'Audio output',
17
- 'AirPlay receiver',
18
- 'Volume up',
19
- 'Volume down',
20
- 'Mute or unmute',
21
- 'Toggle skip broken streams',
22
- 'Resume last station on launch',
23
- 'Transparent background',
24
- 'ASCII-safe display',
25
- 'Reduce motion',
26
- 'Check for updates',
27
- 'Refresh provider health',
28
- 'Learn previous media key',
29
- 'Learn play/pause media key',
30
- 'Learn next media key',
31
- 'Reset learned media keys'
11
+ export const settingsGroups = [
12
+ {
13
+ label: 'Playback',
14
+ items: [
15
+ 'Audio output',
16
+ 'AirPlay receiver',
17
+ 'Volume up',
18
+ 'Volume down',
19
+ 'Mute or unmute',
20
+ 'Toggle skip broken streams',
21
+ 'Resume last station on launch'
22
+ ]
23
+ },
24
+ {
25
+ label: 'Display',
26
+ items: [
27
+ 'Cycle display color',
28
+ 'Cycle receiver style',
29
+ 'Transparent background',
30
+ 'ASCII-safe display',
31
+ 'Reduce motion',
32
+ 'Mouse and trackpad scrolling'
33
+ ]
34
+ },
35
+ {
36
+ label: 'Discovery & privacy',
37
+ items: [
38
+ 'Toggle nearby location lookup',
39
+ 'Toggle Radio Garden experimental adapter',
40
+ 'Share favorite votes with Radio Browser'
41
+ ]
42
+ },
43
+ {
44
+ label: 'Data',
45
+ items: [
46
+ 'Export preferences and library',
47
+ 'Import preferences and library'
48
+ ]
49
+ },
50
+ {
51
+ label: 'Media keys',
52
+ items: [
53
+ 'Learn previous media key',
54
+ 'Learn play/pause media key',
55
+ 'Learn next media key',
56
+ 'Reset learned media keys'
57
+ ]
58
+ },
59
+ {
60
+ label: 'Maintenance',
61
+ items: [
62
+ 'Refresh provider health',
63
+ 'Check for updates'
64
+ ]
65
+ }
32
66
  ];
67
+ export const settingsItems = settingsGroups.flatMap(group => group.items);
68
+ export function settingsSectionFor(item) {
69
+ return settingsGroups.find(group => group.items.some(candidate => candidate === item))?.label ?? 'Preferences';
70
+ }
@@ -0,0 +1,35 @@
1
+ export const primaryScreens = [
2
+ 'home',
3
+ 'now-playing',
4
+ 'library',
5
+ 'explore',
6
+ 'search',
7
+ 'countries',
8
+ 'nearby',
9
+ 'stats',
10
+ 'settings'
11
+ ];
12
+ export const screenMetadata = {
13
+ home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
14
+ 'now-playing': { tabLabel: 'Playing', title: 'Now playing', description: 'Receiver and playback controls' },
15
+ library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, recents, and imported streams' },
16
+ explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
17
+ search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
18
+ countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
19
+ nearby: { tabLabel: 'Nearby', title: 'Nearby', description: 'Find stations near your approximate location' },
20
+ stats: { tabLabel: 'Stats', title: 'Listening stats', description: 'History, streaks, stations, and time' },
21
+ settings: { tabLabel: 'Settings', title: 'Settings', description: 'Playback, display, privacy, and data' }
22
+ };
23
+ const secondaryTitles = {
24
+ stations: 'Stations',
25
+ map: 'World map',
26
+ 'airplay-settings': 'AirPlay',
27
+ 'airplay-code': 'AirPlay code',
28
+ help: 'Help'
29
+ };
30
+ export function screenTitle(screen) {
31
+ if (screen in screenMetadata) {
32
+ return screenMetadata[screen].title;
33
+ }
34
+ return secondaryTitles[screen] ?? 'RadioCLI';
35
+ }
@@ -6,7 +6,8 @@ import { Menu, Pointer } from '../components/Menu.js';
6
6
  import { ScreenHeader } from '../components/ScreenHeader.js';
7
7
  import { truncate } from '../format.js';
8
8
  import { textDim, textMuted, themeAccent } from '../theme.js';
9
- export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width }) {
9
+ import { visibleWindow } from '../list-window.js';
10
+ export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
10
11
  const accent = themeAccent(theme);
11
12
  const availability = airPlayAvailability(backends, devices);
12
13
  const selectedDevice = selectedAirPlayDevice(settings, devices);
@@ -14,7 +15,8 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
14
15
  const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
15
16
  const lineWidth = Math.max(24, width - 4);
16
17
  const preferredOutput = audioOutputLabel(settings.preferredBackend);
17
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
18
+ const deviceWindow = visibleWindow(devices, selected, height ? Math.max(1, height - 9) : Math.max(1, devices.length));
19
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: deviceWindow.items, selected: selected - deviceWindow.start, keyFor: device => device.id, render: (device, _index, active) => {
18
20
  const isSelected = device.id === settings.preferredAirPlayDevice;
19
21
  const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
20
22
  const selectedLabel = isSelected ? ' selected' : '';
@@ -4,13 +4,16 @@ import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
- import { truncate } from '../format.js';
7
+ import { displayWidth, truncate } from '../format.js';
8
8
  export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
9
9
  const window = visibleWindow(countries, selected, pageSize);
10
10
  const rowWidth = Math.max(24, width - 2);
11
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: textMuted, children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => {
12
- const meta = ` · ${country.code} · ${country.stationCount.toLocaleString()} stations`;
13
- const nameWidth = Math.max(4, rowWidth - 2 - meta.length);
14
- return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: truncate(meta, Math.max(0, rowWidth - 2 - nameWidth)) })] }));
11
+ const countWidth = Math.max(12, Math.min(22, Math.max(...countries.map(country => `${country.stationCount.toLocaleString()} stations`.length), 12)));
12
+ const visibleCountryWidth = Math.max(8, ...window.items.map(country => displayWidth(`${country.name} (${country.code})`)));
13
+ const countryWidth = Math.min(42, visibleCountryWidth, Math.max(8, rowWidth - countWidth - 4));
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => {
15
+ const code = ` (${country.code})`;
16
+ const nameWidth = Math.max(3, countryWidth - displayWidth(code));
17
+ return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: countryWidth, children: [_jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
15
18
  } })] })) : null] }));
16
19
  }
@@ -13,7 +13,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, th
13
13
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
14
14
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
15
15
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
16
- return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
16
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2), showCount: false, emptyTitle: loading ? 'Loading stations…' : 'No stations near this point.', emptyHint: "Move the map cursor to explore another area." })] })] })] }));
17
17
  }
18
18
  function CosmoMapLine({ row, theme }) {
19
19
  const { ascii } = useDisplay();
@@ -3,10 +3,24 @@ import { Box, Text } from 'ink';
3
3
  import { textMuted, themeAccent } from '../theme.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { commandHelp, keyHelpSections } from '../help-content.js';
6
- import { truncate } from '../format.js';
7
- export function HelpScreen({ theme, width }) {
6
+ import { padDisplayEnd, truncate } from '../format.js';
7
+ import { visibleWindow } from '../list-window.js';
8
+ export function HelpScreen({ theme, width, height, selected }) {
8
9
  const accent = themeAccent(theme);
9
10
  const keyColumnWidth = 16;
10
11
  const lineWidth = Math.max(28, width - 2);
11
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: textMuted, children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: textMuted, children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
12
+ const rows = [
13
+ ...keyHelpSections.flatMap(section => [
14
+ { key: `section-${section.title}`, heading: section.title },
15
+ ...section.entries.map(entry => ({ key: `${section.title}-${entry.keys}`, ...entry }))
16
+ ]),
17
+ { key: 'commands', heading: 'Commands (press : then type)' },
18
+ ...commandHelp.map(command => ({
19
+ key: `command-${command.name}`,
20
+ keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`,
21
+ description: command.description
22
+ }))
23
+ ];
24
+ const window = visibleWindow(rows, selected, Math.max(3, height - 4));
25
+ return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth) }), _jsx(Text, { color: textMuted, children: truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)) })] }, row.key))) }), _jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", rows.length, " \u00B7 \u2191/\u2193 scroll"] })] }));
12
26
  }
@@ -4,7 +4,6 @@ import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
- import { playbackBackendLabel } from '../../player/backend-install.js';
8
- export function HomeScreen({ selected, theme, library, playback }) {
9
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
7
+ export function HomeScreen({ selected, theme, library }) {
8
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
10
9
  }
@@ -6,7 +6,7 @@ import { toAsciiSafe } from '../ascii.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
7
  import { Menu, Pointer } from '../components/Menu.js';
8
8
  import { ScreenHeader } from '../components/ScreenHeader.js';
9
- import { truncate } from '../format.js';
9
+ import { padDisplayEnd, truncate } from '../format.js';
10
10
  import { buildWorldMap } from '../world-map.js';
11
11
  export function MapScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, mode, width }) {
12
12
  const { ascii } = useDisplay();
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
18
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
19
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
20
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
22
22
  }
23
23
  function MapLine({ row, theme }) {
24
24
  const { ascii } = useDisplay();
@@ -1,12 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
3
+ import { displayWidth, stationLocation, truncate } from '../format.js';
4
4
  import { textMuted, themeAccent } from '../theme.js';
5
5
  import { panelBorderStyle, useDisplay } from '../display-context.js';
6
6
  import { toAsciiSafe } from '../ascii.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
9
- export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
9
+ // Terminal rows are roughly twice as tall as columns are wide. One vertical
10
+ // row and three horizontal columns produce a closer optical inset around the
11
+ // highest and lowest visible text than a mechanically equal cell count.
12
+ const receiverPaddingX = 3;
13
+ const receiverPaddingY = 1;
14
+ export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
10
15
  const { panel: panelBackground, ascii } = useDisplay();
11
16
  // In ASCII mode route every rendered string through the glyph mapper so no
12
17
  // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
@@ -15,30 +20,29 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
15
20
  const accent = themeAccent(theme);
16
21
  const panelWidth = Math.max(62, width);
17
22
  const panelHeight = Math.max(10, height);
18
- const innerWidth = Math.max(28, panelWidth - 6);
19
- const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 14 : 9), innerWidth);
23
+ const innerWidth = Math.max(28, panelWidth - (receiverPaddingX * 2) - 2);
24
+ // Station identity now lives in the receiver header. Its former name and
25
+ // location rows become visualizer space, while a shared gutter keeps the
26
+ // visualization equally separated from the header and track title.
27
+ const visualizerGutterRows = 1;
28
+ const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
20
29
  const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
21
- const stationName = station ? truncate(station.name, innerWidth) : 'No station tuned';
22
- const stationPlace = station
23
- ? truncate(stationLocation(station).toUpperCase(), innerWidth)
24
- : 'Choose a station from Library, Explore, Search, Countries, or Nearby.';
30
+ const receiverState = playback.state.toUpperCase();
31
+ const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
32
+ const stationIdentity = receiverStationIdentity(station, identityWidth);
25
33
  const infoFallback = diagnostics.availableBackends.length > 0
26
34
  ? 'Playback backend ready. Choose a station to start tuning.'
27
35
  : 'No playback backend found. Run radiocli doctor for setup help.';
28
- // One compact meta line: stream tech, tags, and the sleep timer only when set.
29
- const sleepSuffix = sleepLabel !== 'Sleep off' ? ` · ${sleepLabel}` : '';
30
- const infoLine = station
31
- ? truncate(`${stationTech(station)} · ${stationTags(station)}${sleepSuffix}`, innerWidth)
32
- : infoFallback;
33
36
  const metadataLine = metadata?.title
34
37
  ? truncate(metadata.title, Math.max(8, innerWidth - 12))
35
- : 'Waiting for ICY track metadata';
36
- const dialLabel = receiverDialLabel(station);
38
+ : station
39
+ ? 'Waiting for ICY track metadata'
40
+ : infoFallback;
37
41
  const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
38
42
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
39
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: textMuted, children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
43
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, children: receiverState })] }), _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
40
44
  ? renderSegments(row.segments)
41
- : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), _jsx(Text, { color: textMuted, children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
45
+ : row.text }, index))) }), _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
42
46
  }
43
47
  function asciifyVisualRow(row) {
44
48
  return {
@@ -54,43 +58,19 @@ export function recentTracksForStation(history, station, limit) {
54
58
  const key = `${station.provider}:${station.id}`;
55
59
  return history.filter(track => track.stationKey === key).slice(0, limit);
56
60
  }
57
- export function receiverDialLabel(station) {
61
+ export function receiverStationIdentity(station, width) {
58
62
  if (!station) {
59
- return 'FM ---';
63
+ return { name: truncate('No station tuned', width), location: '' };
60
64
  }
61
- const frequency = stationFrequency(station.name);
62
- if (frequency) {
63
- return frequency;
65
+ const nameWidth = displayWidth(station.name);
66
+ if (nameWidth >= width - 3) {
67
+ return { name: truncate(station.name, width), location: '' };
64
68
  }
65
- const codec = station.codec?.trim().toUpperCase();
66
- if (station.bitrate && codec) {
67
- return `FM ${String(station.bitrate).padStart(3, '0')}.${codec.slice(0, 1)}`;
68
- }
69
- if (station.bitrate) {
70
- return `FM ${String(station.bitrate).padStart(3, '0')}`;
71
- }
72
- if (codec) {
73
- return `FM ${codec}`;
74
- }
75
- return 'FM LIVE';
76
- }
77
- function stationFrequency(name) {
78
- const frequencyMatch = name.match(/\b(?:(AM|FM)\s*)?(\d{2,4}(?:\.\d{1,2})?)(?:\s*(AM|FM))?\b/i);
79
- if (!frequencyMatch) {
80
- return null;
81
- }
82
- const value = Number(frequencyMatch[2]);
83
- if (!Number.isFinite(value)) {
84
- return null;
85
- }
86
- const explicitBand = (frequencyMatch[1] ?? frequencyMatch[3])?.toUpperCase();
87
- if (explicitBand === 'AM' || (!explicitBand && value >= 520 && value <= 1710)) {
88
- return `AM ${frequencyMatch[2]}`;
89
- }
90
- if (explicitBand === 'FM' || (!explicitBand && value >= 65 && value <= 120)) {
91
- return `FM ${frequencyMatch[2]}`;
92
- }
93
- return null;
69
+ const locationWidth = Math.max(0, width - nameWidth - 3);
70
+ return {
71
+ name: station.name,
72
+ location: truncate(stationLocation(station).toUpperCase(), locationWidth)
73
+ };
94
74
  }
95
75
  function renderSegments(segments) {
96
76
  let offset = 0;
@@ -10,5 +10,5 @@ export function SearchScreen({ query, editing, loading, stations, selected, them
10
10
  const contentWidth = Math.max(40, width);
11
11
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
12
  const inputTextWidth = Math.max(8, inputWidth - 8);
13
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
13
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children: "Searching station directories\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
14
14
  }
@@ -1,9 +1,9 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { truncate } from '../format.js';
6
- import { settingsItems } from '../screen-items.js';
6
+ import { settingsItems, settingsSectionFor } from '../screen-items.js';
7
7
  import { textMuted, themeAccent } from '../theme.js';
8
8
  import { playbackBackendCapabilities } from '../../player/backend-install.js';
9
9
  import { airPlayReceiverSettingValue } from '../airplay-settings.js';
@@ -12,31 +12,62 @@ import { updateStatusText } from '../../update-check.js';
12
12
  export function SettingsScreen({ selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
13
13
  const accent = themeAccent(theme);
14
14
  const lineWidth = Math.max(32, width - 4);
15
- const health = Object.entries(providerHealth);
16
15
  const menuWindow = settingsMenuWindow(selected, height);
17
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: selected - menuWindow.start, keyFor: ({ item }) => item, render: (item, _index, active) => {
16
+ const selectedItem = settingsItems[selected];
17
+ const activeSection = settingsSectionFor(selectedItem);
18
+ const detail = settingDetail({
19
+ item: selectedItem,
20
+ appVersion,
21
+ updateCheck,
22
+ storePath,
23
+ playback,
24
+ providerHealth,
25
+ diagnostics,
26
+ lineWidth
27
+ });
28
+ const valueGap = 3;
29
+ const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
30
+ const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
31
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: activeSection, width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: ({ item }) => item, render: (item, _index, active) => {
18
32
  const label = settingLabel(item.item, updateCheck);
19
33
  const value = settingValue(item.item, settings, diagnostics, backends, airPlayDevices, updateCheck);
20
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: label }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: textMuted, children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
21
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Status" }), _jsxs(Text, { color: textMuted, children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: textMuted, children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: textMuted, children: ["Version: ", _jsxs(Text, { color: accent, children: ["v", appVersion] }), " \u00B7 Update: ", updateStatusText(updateCheck)] }), _jsxs(Text, { color: textMuted, children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: textMuted, children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
34
+ return (_jsxs(Box, { flexDirection: "column", children: [item.showSection ? (_jsx(Text, { color: textMuted, bold: true, children: item.section })) : null, _jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] })] }));
35
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
22
36
  }
23
37
  function settingsMenuWindow(selected, height) {
24
- if (!height) {
25
- return { start: 0, items: settingsItems.map(item => ({ item })) };
26
- }
27
- const reservedRows = 10;
28
- const maxRows = Math.max(5, height - reservedRows);
29
- if (settingsItems.length <= maxRows) {
30
- return { start: 0, items: settingsItems.map(item => ({ item })) };
31
- }
38
+ const reservedRows = 6;
39
+ const maxRows = height ? Math.max(5, height - reservedRows) : Number.POSITIVE_INFINITY;
32
40
  const clampedSelected = Math.min(Math.max(selected, 0), settingsItems.length - 1);
33
- const start = Math.min(Math.floor(clampedSelected / maxRows) * maxRows, Math.max(0, settingsItems.length - maxRows));
41
+ const pages = [];
42
+ let page = [];
43
+ let pageRows = 0;
44
+ let previousSection = '';
45
+ settingsItems.forEach((item, index) => {
46
+ const section = settingsSectionFor(item);
47
+ let showSection = section !== previousSection;
48
+ let rowCost = 1 + (showSection ? 1 : 0);
49
+ if (page.length > 0 && pageRows + rowCost > maxRows) {
50
+ pages.push(page);
51
+ page = [];
52
+ pageRows = 0;
53
+ previousSection = '';
54
+ showSection = true;
55
+ rowCost = 2;
56
+ }
57
+ page.push({ item, section, showSection, index });
58
+ pageRows += rowCost;
59
+ previousSection = section;
60
+ });
61
+ if (page.length > 0) {
62
+ pages.push(page);
63
+ }
64
+ const selectedPage = pages.find(candidate => candidate.some(entry => entry.index === clampedSelected)) ?? pages[0] ?? [];
34
65
  return {
35
- start,
36
- items: settingsItems.slice(start, start + maxRows).map(item => ({ item }))
66
+ items: selectedPage,
67
+ selectedOffset: Math.max(0, selectedPage.findIndex(entry => entry.index === clampedSelected))
37
68
  };
38
69
  }
39
- function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
70
+ export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
40
71
  switch (item) {
41
72
  case 'Cycle display color':
42
73
  return settings.theme;
@@ -46,12 +77,14 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices, upd
46
77
  return settings.enableRadioGarden ? 'on' : 'off';
47
78
  case 'Toggle nearby location lookup':
48
79
  return settings.enableNearbyLocation ? 'on' : 'off';
80
+ case 'Share favorite votes with Radio Browser':
81
+ return settings.shareDirectoryVotes ? 'on' : 'off';
49
82
  case 'Audio output':
50
83
  return audioOutputSettingValue(settings, diagnostics, backends);
51
84
  case 'AirPlay receiver':
52
85
  return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
53
86
  case 'Mute or unmute':
54
- if (diagnostics.backend === 'ffplay' && !playbackBackendCapabilities(diagnostics.backend).supportsMute) {
87
+ if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) {
55
88
  return 'requires mpv';
56
89
  }
57
90
  return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
@@ -65,17 +98,56 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices, upd
65
98
  return settings.asciiMode ? 'on' : 'off';
66
99
  case 'Reduce motion':
67
100
  return settings.reduceMotion ? 'on' : 'off';
101
+ case 'Mouse and trackpad scrolling':
102
+ return settings.mouseSupport === false ? 'off' : 'auto';
68
103
  case 'Check for updates':
69
104
  return updateStatusText(updateCheck);
70
105
  case 'Reset learned media keys':
71
106
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
107
+ case 'Export preferences and library':
108
+ return 'JSON backup';
109
+ case 'Import preferences and library':
110
+ return 'restore JSON backup';
72
111
  default:
73
112
  return undefined;
74
113
  }
75
114
  }
76
- function settingLabel(item, updateCheck) {
115
+ export function settingLabel(item, updateCheck) {
77
116
  if (item === 'Check for updates' && updateCheck?.updateAvailable) {
78
117
  return 'Install update';
79
118
  }
80
- return item;
119
+ return settingDisplayLabels[item] ?? item;
120
+ }
121
+ const settingDisplayLabels = {
122
+ 'Cycle display color': 'Display color',
123
+ 'Cycle receiver style': 'Receiver style',
124
+ 'Toggle nearby location lookup': 'Nearby location',
125
+ 'Toggle Radio Garden experimental adapter': 'Radio Garden adapter',
126
+ 'Toggle skip broken streams': 'Skip broken streams'
127
+ };
128
+ function settingDetail(input) {
129
+ const { item, appVersion, updateCheck, storePath, playback, providerHealth, diagnostics, lineWidth } = input;
130
+ if (item === 'Audio output') {
131
+ return `Active: ${audioOutputLabel(playback.backend)} · ${playback.state} · ${diagnostics.muted ? 'muted' : `volume ${diagnostics.volume}`}`;
132
+ }
133
+ if (item === 'Refresh provider health') {
134
+ const health = Object.entries(providerHealth);
135
+ return health.length ? health.map(([provider, status]) => `${provider}: ${status}`).join(' · ') : 'Press Enter to check station providers.';
136
+ }
137
+ if (item === 'Check for updates') {
138
+ return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck)}`;
139
+ }
140
+ if (item === 'Export preferences and library' || item === 'Import preferences and library') {
141
+ return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
142
+ }
143
+ if (item === 'Share favorite votes with Radio Browser') {
144
+ return 'When enabled, favoriting also sends a public directory vote.';
145
+ }
146
+ if (item === 'Toggle nearby location lookup') {
147
+ return 'Uses approximate IP location only when you open Nearby.';
148
+ }
149
+ if (item === 'Reduce motion') {
150
+ return 'Freezes animated receiver displays for calmer, lower-power rendering.';
151
+ }
152
+ return 'Press Enter to change this setting.';
81
153
  }
@@ -4,5 +4,18 @@ import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { textMuted } from '../theme.js';
6
6
  export function StationScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, pageSize, width }) {
7
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] })] }));
7
+ const empty = stationEmptyState(title);
8
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: empty.title, emptyHint: empty.hint })) : null] })] }));
9
+ }
10
+ function stationEmptyState(title) {
11
+ if (title.toLowerCase().includes('library')) {
12
+ return { title: 'Your library is empty.', hint: 'Press f on any station to save it here.' };
13
+ }
14
+ if (title.toLowerCase().includes('nearby')) {
15
+ return { title: 'No nearby stations found.', hint: 'Try again later or browse Countries.' };
16
+ }
17
+ if (title.toLowerCase().includes('country')) {
18
+ return { title: 'No stations found for this country.', hint: 'Choose another country or clear active filters.' };
19
+ }
20
+ return { title: 'No stations found.', hint: 'Try another view or clear active filters.' };
8
21
  }