@ciphore/radiocli 0.2.3 → 0.2.4

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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -15,7 +15,7 @@ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
15
  import { selectedAirPlayDevice } from './airplay-settings.js';
16
16
  import { AdaptiveContent } from './AdaptiveContent.js';
17
17
  import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
18
- export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
18
+ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui, airPlaySupported = true }) {
19
19
  if (layout.compact) {
20
20
  if (screen === 'alarms')
21
21
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -25,7 +25,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
25
25
  return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
26
26
  if (screen === 'alarm-ringing')
27
27
  return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
28
- return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
28
+ return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel, airPlaySupported: airPlaySupported }));
29
29
  }
30
30
  if (screen === 'alarms')
31
31
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -60,7 +60,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
60
60
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
61
61
  }
62
62
  if (screen === 'settings') {
63
- return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
63
+ return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows, airPlaySupported: airPlaySupported }));
64
64
  }
65
65
  if (screen === 'airplay-settings') {
66
66
  return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
@@ -38,7 +38,7 @@ export const initialStationContexts = {
38
38
  },
39
39
  library: {
40
40
  title: 'Library',
41
- subtitle: 'Favorites, recent stations, and imported streams',
41
+ subtitle: 'Favorites, imported streams, and recent stations',
42
42
  stations: []
43
43
  }
44
44
  };
@@ -58,6 +58,13 @@ export function searchEditingArrowAction(key, hasResults) {
58
58
  }
59
59
  return null;
60
60
  }
61
+ export function shouldTuneSearchResult(query, lastSubmittedQuery) {
62
+ const trimmedQuery = query.trim();
63
+ return !trimmedQuery || trimmedQuery === lastSubmittedQuery.trim();
64
+ }
65
+ export function isSearchFavoriteShortcut(input, key) {
66
+ return input.toLowerCase() === 'f' && Boolean(key.ctrl || key.meta || key.super);
67
+ }
61
68
  export function clampVolume(value) {
62
69
  return Math.min(100, Math.max(0, Math.round(value)));
63
70
  }
package/dist/ui/ascii.js CHANGED
@@ -64,6 +64,14 @@ function asciiGlyph(code) {
64
64
  if (code === 0x25c9 || code === 0x25d2) {
65
65
  return 'o'; // ◉ ◒
66
66
  }
67
+ if (code === 0x2218)
68
+ return 'o'; // ring operator used by receiver patterns
69
+ if (code === 0x25c6 || code === 0x25c7)
70
+ return '*'; // decorative diamonds
71
+ if (code === 0x201c || code === 0x201d)
72
+ return '"';
73
+ if (code === 0x2018 || code === 0x2019)
74
+ return "'";
67
75
  if (code === 0x25b2) {
68
76
  return '^'; // ▲
69
77
  }
@@ -105,8 +113,9 @@ function asciiGlyph(code) {
105
113
  return '^';
106
114
  if (code === 0x2193)
107
115
  return 'v';
108
- // Drop zero-width and combining marks entirely so they do not skew widths.
109
- if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
116
+ // Drop invisible separators, but retain combining accents in station data.
117
+ // Width measurement already accounts for combining marks as zero columns.
118
+ if (code === 0x200b || code === 0xfeff) {
110
119
  return '';
111
120
  }
112
121
  // Unknown Unicode is user/content data more often than decoration. Preserve
@@ -2,16 +2,19 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Text } from 'ink';
4
4
  import { displayWidth, sliceDisplay, truncate } from '../format.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { toAsciiSafe } from '../ascii.js';
5
7
  const edgePauseMs = 1000;
6
8
  const stepMs = 180;
7
9
  export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
10
+ const { ascii, reduceMotion: terminalReduceMotion } = useDisplay();
8
11
  const safeWidth = Math.max(0, width);
9
12
  const maxOffset = Math.max(0, displayWidth(text) - safeWidth);
10
- const animated = active && !reduceMotion && maxOffset > 0;
13
+ const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0;
11
14
  const [offset, setOffset] = useState(0);
12
15
  useEffect(() => {
13
16
  setOffset(0);
14
- }, [active, reduceMotion, safeWidth, text]);
17
+ }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]);
15
18
  useEffect(() => {
16
19
  if (!animated)
17
20
  return;
@@ -24,5 +27,5 @@ export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
24
27
  const visible = animated
25
28
  ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth)
26
29
  : truncate(text, safeWidth);
27
- return _jsx(Text, { children: visible });
30
+ return _jsx(Text, { "aria-label": text, children: ascii ? toAsciiSafe(visible) : visible });
28
31
  }
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { textMuted } from '../theme.js';
4
+ import { useDisplay } from '../display-context.js';
4
5
  const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
5
6
  function LogoSpectrum() {
6
- return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
7
+ const { ascii } = useDisplay();
8
+ return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '##' : '██' }, color))) }));
7
9
  }
8
10
  export function Logo({ compact = false, width = 80 } = {}) {
11
+ const { ascii } = useDisplay();
9
12
  if (compact) {
10
13
  const spectrumColumns = Math.max(1, width - 10);
11
14
  const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns));
12
- return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, children: "\u2588" }, color))] }));
15
+ return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '#' : '█' }, color))] }));
13
16
  }
14
17
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: textMuted, children: "Live public radio from around the world" })] }));
15
18
  }
@@ -2,10 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export function Menu({ items, selected, keyFor, render }) {
4
4
  const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
5
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
5
+ return (_jsx(Box, { flexDirection: "column", "aria-role": "list", children: items.map((item, position) => (_jsx(Box, { "aria-role": "listitem", "aria-state": { selected: position === activeIndex }, children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
6
6
  }
7
7
  export function Pointer({ active }) {
8
- return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
8
+ return _jsx(Text, { bold: active, "aria-hidden": true, children: active ? '> ' : ' ' });
9
9
  }
10
10
  function defaultMenuKey(item) {
11
11
  if (typeof item === 'string' || typeof item === 'number') {
@@ -15,5 +15,5 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
15
15
  const rightText = right ? ` ${right}` : '';
16
16
  const ruleWidth = Math.max(1, width - displayWidth(safeTitle) - displayWidth(rightText) - 2);
17
17
  const rule = (ascii ? '-' : '─').repeat(ruleWidth);
18
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
18
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, "aria-hidden": true, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
19
19
  }
@@ -6,10 +6,12 @@ import { Menu, Pointer } from './Menu.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
7
  import { useDisplay } from '../display-context.js';
8
8
  import { AdaptiveMarquee } from './AdaptiveMarquee.js';
9
+ import { toAsciiSafe } from '../ascii.js';
9
10
  export function StationList({ stations, selected, theme, favorites, pageSize, width, emptyTitle = 'No stations found.', emptyHint = 'Try another view or clear active filters.', showCount = true }) {
10
- const { reduceMotion } = useDisplay();
11
+ const { reduceMotion, ascii } = useDisplay();
12
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
11
13
  if (stations.length === 0) {
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: emptyTitle }), _jsx(Text, { color: textMuted, children: emptyHint })] }));
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: a(emptyTitle) }), _jsx(Text, { color: textMuted, children: a(emptyHint) })] }));
13
15
  }
14
16
  const window = visibleWindow(stations, selected, pageSize);
15
17
  const rowWidth = Math.max(42, width - 4);
@@ -19,12 +21,12 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
19
21
  const favorite = favorites.has(`${station.provider}:${station.id}`);
20
22
  if (width < 42) {
21
23
  const compactNameWidth = Math.max(1, width - 2 - (favorite ? 2 : 0));
22
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }));
24
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }));
23
25
  }
24
26
  const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
25
27
  const titleWidth = nameWidth + 2;
26
28
  const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
27
29
  const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
28
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
30
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: a(truncate(active ? selectedMetadata : standardMetadata, metaWidth)) })] }));
29
31
  } })] }));
30
32
  }
@@ -21,7 +21,7 @@ export function TopTabs({ tabs, active, theme, width, backendLabel }) {
21
21
  const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
22
22
  const visibleTabsWidth = tabsWidth(visibleTabs);
23
23
  const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0));
24
- return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, "aria-label": `RadioCLI. Tabs: ${tabs.map(tab => `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: ascii ? '.' : '…' })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
25
25
  }
26
26
  function fitTabs(tabs, active, maxWidth) {
27
27
  const all = tabs.map(tab => ({ type: 'tab', tab }));
@@ -1,19 +1,16 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import { resolveTerminalCapabilities } from '../platform/terminal.js';
2
3
  import { appBackground, panelBackground } from './theme.js';
3
4
  const opaqueBackgrounds = { app: appBackground, panel: panelBackground };
4
5
  const transparentBackgrounds = { app: undefined, panel: undefined };
5
- // Respect the NO_COLOR convention (https://no-color.org): any non-empty value
6
- // opts the user out of our forced colors, including the dark panel fills.
7
- export function noColorRequested(env = process.env) {
8
- return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0;
6
+ // Sixteen-color palettes cannot safely reproduce our dark fills. Let those
7
+ // terminals keep their own background while Ink quantizes the foregrounds.
8
+ export function resolveDisplayMode(settings, env = process.env, evidence = {}) {
9
+ const terminal = resolveTerminalCapabilities(env, { ...settings, ...evidence });
10
+ const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds;
11
+ return { ...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel };
9
12
  }
10
- // Resolve the effective display mode from the user's settings and the NO_COLOR
11
- // environment so light terminals keep their own background.
12
- export function resolveDisplayMode(settings, env = process.env) {
13
- const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds;
14
- return { ...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion) };
15
- }
16
- const defaultMode = { ...opaqueBackgrounds, ascii: false, reduceMotion: false };
13
+ const defaultMode = resolveDisplayMode({}, {});
17
14
  export const DisplayContext = createContext(defaultMode);
18
15
  export function useDisplay() {
19
16
  return useContext(DisplayContext);
@@ -20,7 +20,7 @@ export const keyHelpSections = [
20
20
  { keys: ', / . or F7/F9', description: 'Previous / next station' },
21
21
  { keys: '+ / -', description: 'Volume up / down' },
22
22
  { keys: 'm', description: 'Mute / unmute' },
23
- { keys: 'f', description: 'Favorite the station' },
23
+ { keys: 'f / Ctrl+F', description: 'Favorite the station (Ctrl+F while typing in Search)' },
24
24
  { keys: 'O', description: 'Open station homepage' },
25
25
  { keys: 'y', description: 'Copy stream URL' },
26
26
  { keys: 's', description: 'Sleep timer (Now Playing)' },
@@ -70,7 +70,7 @@ export const commandHelp = [
70
70
  { name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
71
71
  { name: 'motion', args: '<on|off>', description: 'Reduce motion' },
72
72
  { name: 'background', args: '<on|off>', description: 'Transparent background' },
73
- { name: 'airplay', description: 'Open AirPlay settings' },
73
+ { name: 'airplay', description: 'Open AirPlay settings (macOS only)' },
74
74
  { name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
75
75
  { name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
76
76
  { name: 'favorite', description: 'Favorite the current station (alias :fav)' },
@@ -78,8 +78,9 @@ export const commandHelp = [
78
78
  { name: 'map', description: 'Open the world map' },
79
79
  { name: 'stats', description: 'Open listening stats' },
80
80
  { name: 'settings', description: 'Open settings' },
81
- { name: 'export', args: '[backup.json]', description: 'Export preferences and library' },
82
- { name: 'import', args: '<backup.json>', description: 'Restore preferences and library' },
81
+ { name: 'import', args: '<stream-url> [name]', description: 'Add a custom station and discover its details' },
82
+ { name: 'export', args: '[backup.json]', description: 'Back up preferences and library' },
83
+ { name: 'restore', args: '<backup.json>', description: 'Restore preferences and library' },
83
84
  { name: 'update', description: 'Show the install command for the latest release' },
84
85
  { name: 'stop', description: 'Stop playback' },
85
86
  { name: 'help', description: 'Open this help' }
@@ -103,7 +104,6 @@ export const commandNames = [
103
104
  'airplay-code',
104
105
  'airplay-settings',
105
106
  'backup',
106
- 'restore',
107
107
  ...commandHelp.map(entry => entry.name)
108
108
  ].sort((a, b) => a.localeCompare(b));
109
109
  // Complete a partial command name to the longest shared prefix of its matches.
package/dist/ui/layout.js CHANGED
@@ -18,8 +18,11 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
18
18
  const reservedFooterRows = Math.min(desiredFooterRows, Math.max(0, safeRows - topRows - 1));
19
19
  const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
20
20
  const mapMode = frameWidth >= 88 && contentRows >= 24 ? 'full' : 'compact';
21
- const stationRows = clamp(contentRows - 6, 1, 48);
22
- const countryRows = clamp(contentRows - 5, 1, 64);
21
+ // Derive list windows from the live terminal height. Capping these values
22
+ // leaves the lower half of tall/full-screen terminals unused while the
23
+ // selection continues scrolling through rows that could already be visible.
24
+ const stationRows = Math.max(1, contentRows - 6);
25
+ const countryRows = Math.max(1, contentRows - 5);
23
26
  return {
24
27
  columns: safeColumns,
25
28
  rows: safeRows,
@@ -13,7 +13,7 @@ export function fullStatusFooterRows(screen, message, footerMessage, playbackSta
13
13
  { key: 'playback', text: footerMessage ?? playbackStatus ?? ' ' }
14
14
  ];
15
15
  }
16
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen, settingsPage = 'root' }) {
16
+ export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, airPlaySupported = true, playbackBackend, screen, settingsPage = 'root' }) {
17
17
  if (capturingTransportAction) {
18
18
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
19
19
  }
@@ -24,7 +24,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
24
24
  return '↑/↓ move · Enter open · number jump · l location · : command';
25
25
  }
26
26
  if (screen === 'search' && editingSearch) {
27
- return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
27
+ return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish';
28
28
  }
29
29
  if (screen === 'search') {
30
30
  return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b Overview';
@@ -59,7 +59,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
59
59
  }
60
60
  if (screen === 'settings') {
61
61
  return settingsPage === 'root'
62
- ? '↑/↓ choose · Enter open · o output · a AirPlay · b Overview'
62
+ ? `↑/↓ choose · Enter open · o output${airPlaySupported ? ' · a AirPlay' : ''} · b Overview`
63
63
  : '↑/↓ choose · Enter change · b Settings · shortcuts still work';
64
64
  }
65
65
  if (screen === 'airplay-settings') {
@@ -1,6 +1,6 @@
1
1
  export const homeItems = [
2
2
  { screen: 'now-playing', label: 'Playing', detail: 'Receiver display and controls' },
3
- { screen: 'library', label: 'Library', detail: 'Favorites, recent stations, imported streams' },
3
+ { screen: 'library', label: 'Library', detail: 'Favorites, imported streams, recent stations' },
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' },
@@ -54,7 +54,7 @@ export const settingsGroups = [
54
54
  description: 'Back up or restore preferences and saved stations',
55
55
  items: [
56
56
  'Export preferences and library',
57
- 'Import preferences and library'
57
+ 'Restore preferences and library'
58
58
  ]
59
59
  },
60
60
  {
@@ -12,7 +12,7 @@ export const primaryScreens = [
12
12
  export const screenMetadata = {
13
13
  home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
14
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' },
15
+ library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, imported streams, and recents' },
16
16
  explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
17
17
  search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
18
18
  countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { ScreenHeader } from '../components/ScreenHeader.js';
4
4
  import { truncate } from '../format.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { toAsciiSafe } from '../ascii.js';
5
7
  import { textDim, textMuted, themeAccent } from '../theme.js';
6
8
  export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
9
+ const { ascii } = useDisplay();
10
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
7
11
  const accent = themeAccent(theme);
8
12
  const lineWidth = Math.max(24, width - 4);
9
13
  const promptActive = isAirPlayCodePromptActive(playback);
10
14
  const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
11
15
  const masked = code.length > 0 ? '*'.repeat(code.length) : '';
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
16
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: a(truncate(receiverName, Math.max(8, lineWidth - 10))) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
13
17
  ? 'The receiver is asking for a code now.'
14
- : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
18
+ : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: a('Enter submits · Backspace edits · Esc returns to AirPlay') })] })] }));
15
19
  }
16
20
  export function isAirPlayCodePromptActive(playback) {
17
21
  return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
@@ -7,7 +7,11 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
7
7
  import { truncate } from '../format.js';
8
8
  import { textDim, textMuted, themeAccent } from '../theme.js';
9
9
  import { visibleWindow } from '../list-window.js';
10
+ import { useDisplay } from '../display-context.js';
11
+ import { toAsciiSafe } from '../ascii.js';
10
12
  export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
13
+ const { ascii } = useDisplay();
14
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
11
15
  const accent = themeAccent(theme);
12
16
  const availability = airPlayAvailability(backends, devices);
13
17
  const selectedDevice = selectedAirPlayDevice(settings, devices);
@@ -16,11 +20,11 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
16
20
  const lineWidth = Math.max(24, width - 4);
17
21
  const preferredOutput = audioOutputLabel(settings.preferredBackend);
18
22
  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) => {
23
+ 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 }), a(' · Streaming: '), _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), a(' · Receivers: '), _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: a(truncate(availability.detail, lineWidth)) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: a(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: ", a(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) => {
20
24
  const isSelected = device.id === settings.preferredAirPlayDevice;
21
25
  const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
22
26
  const selectedLabel = isSelected ? ' selected' : '';
23
27
  const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
24
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: textMuted, children: [" \u00B7 ", detail] })] }));
25
- } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
28
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(name) }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsx(Text, { color: textMuted, children: a(` · ${detail}`) })] }));
29
+ } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers') })] })] }));
26
30
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
+ import { formatExploreCursor } from '../app-state.js';
4
5
  import { StationList } from '../components/StationList.js';
5
6
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
7
  import { computeExploreMapLayout } from '../explore-map-layout.js';
@@ -14,7 +15,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, er
14
15
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
15
16
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
16
17
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
17
- 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: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, 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
18
+ 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", "aria-label": `World map cursor: ${formatExploreCursor(cursor)}`, children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, 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
18
19
  ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
19
20
  : error
20
21
  ? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
@@ -5,7 +5,11 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { commandHelp, keyHelpSections } from '../help-content.js';
6
6
  import { padDisplayEnd, truncate } from '../format.js';
7
7
  import { visibleWindow } from '../list-window.js';
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
8
10
  export function HelpScreen({ theme, width, height, selected }) {
11
+ const { ascii } = useDisplay();
12
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
9
13
  const accent = themeAccent(theme);
10
14
  const keyColumnWidth = 16;
11
15
  const lineWidth = Math.max(28, width - 2);
@@ -22,5 +26,5 @@ export function HelpScreen({ theme, width, height, selected }) {
22
26
  }))
23
27
  ];
24
28
  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"] })] }));
29
+ 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: a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)) }), _jsx(Text, { color: textMuted, children: a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))) })] }, row.key))) }), _jsx(Text, { color: textMuted, children: a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`) })] }));
26
30
  }
@@ -5,7 +5,11 @@ import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
7
  import { displayWidth } from '../format.js';
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
8
10
  export function HomeScreen({ selected, theme, library }) {
11
+ const { ascii } = useDisplay();
12
+ const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`;
9
13
  const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2;
10
- 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(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", 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"] }) })] }));
14
+ 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(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(summary) : summary }) })] }));
11
15
  }
@@ -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: ${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))}`) })] })) }) })] })] }));
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", "aria-hidden": true, 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();
@@ -14,7 +14,7 @@ const receiverPaddingX = 3;
14
14
  const receiverPaddingY = 1;
15
15
  export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
16
16
  const pulse = useReceiverPulse();
17
- const { panel: panelBackground, ascii } = useDisplay();
17
+ const { panel: panelBackground, ascii, screenReader } = useDisplay();
18
18
  // In ASCII mode route every rendered string through the glyph mapper so no
19
19
  // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
20
20
  const a = (value) => (ascii ? toAsciiSafe(value) : value);
@@ -28,7 +28,7 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
28
28
  // visualization equally separated from the header and track title.
29
29
  const visualizerGutterRows = 1;
30
30
  const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
31
- const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
31
+ const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
32
32
  const receiverState = playback.state.toUpperCase();
33
33
  const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
34
34
  const stationIdentity = receiverStationIdentity(station, identityWidth);
@@ -42,9 +42,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
42
42
  : infoFallback;
43
43
  const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
44
44
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
45
- 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
45
+ 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", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, 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, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
46
46
  ? renderSegments(row.segments)
47
- : 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] })] }));
47
+ : row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', 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] })] }));
48
48
  }
49
49
  function asciifyVisualRow(row) {
50
50
  return {
@@ -5,10 +5,12 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { panelBorder, textMuted, themeAccent } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { truncate } from '../format.js';
8
+ import { toAsciiSafe } from '../ascii.js';
8
9
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
9
10
  const { panel: panelBackground, ascii } = useDisplay();
11
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
10
12
  const contentWidth = Math.max(40, width);
11
13
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
14
  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 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] }));
15
+ 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: a(editing ? '› ' : ' ') }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)) })] }), loading ? _jsx(Text, { color: textMuted, children: a('Searching station directories…') }) : 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
16
  }