@ciphore/radiocli 0.2.2 → 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 (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -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, 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, 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, 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 });
@@ -39,7 +39,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
39
39
  return _jsx(HomeScreen, { selected: selected, theme: theme, library: library });
40
40
  }
41
41
  if (screen === 'countries') {
42
- return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
42
+ return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth, reduceMotion: Boolean(library.settings.reduceMotion) }));
43
43
  }
44
44
  if (screen === 'map') {
45
45
  return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
@@ -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, { 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
  }
@@ -1,15 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { displayWidth, stationLocation, stationTags, stationTech, truncate } from '../format.js';
3
+ import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
4
  import { textMuted, themeAccent } from '../theme.js';
5
5
  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,14 +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
- const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
26
+ const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
25
27
  const titleWidth = nameWidth + 2;
26
- const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
27
- const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
28
28
  const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
29
29
  const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
30
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _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)) })] }));
31
31
  } })] }));
32
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 }));
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { updateAvailableForVersion } from '../../update-check.js';
4
+ import { displayWidth } from '../format.js';
5
+ import { appBackground, textDim, themeAccent } from '../theme.js';
6
+ export function availableUpdateLabel(currentVersion, updateCheck) {
7
+ return updateAvailableForVersion(updateCheck, currentVersion) && updateCheck?.latestVersion
8
+ ? `v${updateCheck.latestVersion} available`
9
+ : undefined;
10
+ }
11
+ export function versionIndicatorWidth(currentVersion, updateCheck) {
12
+ const versionWidth = displayWidth(`v${currentVersion}`);
13
+ const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
14
+ return updateLabel ? displayWidth(updateLabel) + 3 + versionWidth : versionWidth;
15
+ }
16
+ export function VersionIndicator({ currentVersion, updateCheck, theme }) {
17
+ const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
18
+ return (_jsxs(Box, { flexShrink: 0, children: [updateLabel ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: appBackground, backgroundColor: themeAccent(theme), bold: true, children: ` ${updateLabel} ` }), _jsx(Text, { children: " " })] })) : null, _jsx(Text, { color: textDim, children: `v${currentVersion}` })] }));
19
+ }
@@ -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 }) {
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';
@@ -58,7 +58,9 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
58
58
  return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b Overview';
59
59
  }
60
60
  if (screen === 'settings') {
61
- return 'Enter change selected · g Radio Garden · l location · x skip · o output · a AirPlay · r health · b Overview';
61
+ return settingsPage === 'root'
62
+ ? `↑/↓ choose · Enter open · o output${airPlaySupported ? ' · a AirPlay' : ''} · b Overview`
63
+ : '↑/↓ choose · Enter change · b Settings · shortcuts still work';
62
64
  }
63
65
  if (screen === 'airplay-settings') {
64
66
  return canEnterAirPlayCode
@@ -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' },
@@ -11,7 +11,9 @@ export const homeItems = [
11
11
  ];
12
12
  export const settingsGroups = [
13
13
  {
14
- label: 'Playback',
14
+ id: 'playback',
15
+ label: 'Playback & audio',
16
+ description: 'Output, volume, AirPlay, and startup behavior',
15
17
  items: [
16
18
  'Audio output',
17
19
  'AirPlay receiver',
@@ -23,7 +25,9 @@ export const settingsGroups = [
23
25
  ]
24
26
  },
25
27
  {
26
- label: 'Display',
28
+ id: 'appearance',
29
+ label: 'Appearance',
30
+ description: 'Colors, receiver style, motion, and terminal display',
27
31
  items: [
28
32
  'Cycle display color',
29
33
  'Cycle receiver style',
@@ -34,22 +38,40 @@ export const settingsGroups = [
34
38
  ]
35
39
  },
36
40
  {
37
- label: 'Discovery & privacy',
41
+ id: 'discovery',
42
+ label: 'Discovery & providers',
43
+ description: 'Location, station directories, privacy, and provider health',
38
44
  items: [
39
45
  'Toggle nearby location lookup',
40
46
  'Toggle Radio Garden experimental adapter',
41
- 'Share favorite votes with Radio Browser'
47
+ 'Share favorite votes with Radio Browser',
48
+ 'Refresh provider health'
42
49
  ]
43
50
  },
44
51
  {
45
- label: 'Data',
52
+ id: 'data',
53
+ label: 'Data & library',
54
+ description: 'Back up or restore preferences and saved stations',
46
55
  items: [
47
56
  'Export preferences and library',
48
- 'Import preferences and library'
57
+ 'Restore preferences and library'
49
58
  ]
50
59
  },
51
60
  {
61
+ id: 'agent',
62
+ label: 'Agent control & MCP',
63
+ description: 'Local agent access and MCP playback behavior',
64
+ items: [
65
+ 'Allow local agent control',
66
+ 'Install or repair MCP integrations',
67
+ 'Open TUI for agent playback',
68
+ 'Show Now Playing for agent playback'
69
+ ]
70
+ },
71
+ {
72
+ id: 'media-keys',
52
73
  label: 'Media keys',
74
+ description: 'Teach RadioCLI your keyboard playback controls',
53
75
  items: [
54
76
  'Learn previous media key',
55
77
  'Learn play/pause media key',
@@ -58,14 +80,23 @@ export const settingsGroups = [
58
80
  ]
59
81
  },
60
82
  {
61
- label: 'Maintenance',
83
+ id: 'updates',
84
+ label: 'Updates',
85
+ description: 'Automatic launch checks, version status, and installation',
62
86
  items: [
63
- 'Refresh provider health',
87
+ 'Automatically check for updates',
64
88
  'Check for updates'
65
89
  ]
66
90
  }
67
91
  ];
68
92
  export const settingsItems = settingsGroups.flatMap(group => group.items);
69
- export function settingsSectionFor(item) {
70
- return settingsGroups.find(group => group.items.some(candidate => candidate === item))?.label ?? 'Preferences';
93
+ export const settingsRootItems = settingsGroups.map(group => group.label);
94
+ export function settingsGroup(page) {
95
+ return settingsGroups.find(group => group.id === page);
96
+ }
97
+ export function settingsItemsForPage(page) {
98
+ return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? [];
99
+ }
100
+ export function settingsPageForRootItem(item) {
101
+ return settingsGroups.find(group => group.label === item)?.id;
71
102
  }
@@ -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
  }
@@ -76,12 +76,13 @@ export function AlarmRingingScreen({ sessions, alarms = [], selected, snoozeMinu
76
76
  const accent = themeAccent(theme);
77
77
  const session = sessions[selected];
78
78
  const label = session ? alarms.find(alarm => alarm.id === session.status.alarmId)?.label : undefined;
79
+ const starting = session?.status.state === 'starting';
79
80
  const { ascii } = useDisplay();
80
81
  const a = (value) => ascii ? toAsciiSafe(value) : value;
81
82
  const roomy = width >= 42 && height >= 12;
82
83
  const clockText = session ? formatClock(new Date(session.status.scheduledAt)) : '';
83
84
  const displayClock = roomy && !ascii ? fullwidthClock(clockText) : clockText;
84
- return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarm ringing", subtitle: sessions.length > 1 ? `${selected + 1} of ${sessions.length} active alarms` : 'Wake radio · playing now', theme: theme, width: width }), session ? _jsxs(Box, { flexDirection: "column", alignItems: "center", width: width, children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(' ALARM ACTIVE ') }), _jsx(Box, { marginTop: roomy ? 1 : 0, children: _jsx(Text, { backgroundColor: roomy ? accent : undefined, color: roomy ? 'black' : accent, bold: true, children: a(roomy ? ` ${displayClock} ` : displayClock) }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: roomy ? 1 : 0, width: width, children: [_jsx(Text, { color: accent, bold: true, children: a(truncate(label ? `${session.status.stationName} · ${label}` : session.status.stationName, width)) }), height >= 12 ? _jsx(Text, { color: textMuted, children: a(truncate(`Started ${formatElapsed(session.status.startedAt)} ago · scheduled ${formatWhen(new Date(session.status.scheduledAt))}`, width)) }) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: width >= 58 ? 'row' : 'column', alignItems: "center", children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(' ENTER KEEP PLAYING ') }), width >= 58 ? _jsx(Text, { children: " " }) : null, _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(` SPACE SNOOZE ${snoozeMinutes} MIN `) })] }), height >= 14 ? _jsx(Text, { color: textMuted, children: a('Enter opens Playing · Space stops the radio until snooze ends · b return') }) : null, sessions.length > 1 && height >= 15 ? _jsx(Text, { color: textMuted, children: a(`↑/↓ choose active alarm · ${selected + 1}/${sessions.length}`) }) : null] }) : _jsx(Text, { color: textMuted, children: "The alarm session ended. Press b to return." })] });
85
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarm ringing", subtitle: sessions.length > 1 ? `${selected + 1} of ${sessions.length} active alarms` : starting ? 'Wake radio · preparing station' : 'Wake radio · playing now', theme: theme, width: width }), session ? _jsxs(Box, { flexDirection: "column", alignItems: "center", width: width, children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ALARM STARTING ' : ' ALARM ACTIVE ') }), _jsx(Box, { marginTop: roomy ? 1 : 0, children: _jsx(Text, { backgroundColor: roomy ? accent : undefined, color: roomy ? 'black' : accent, bold: true, children: a(roomy ? ` ${displayClock} ` : displayClock) }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: roomy ? 1 : 0, width: width, children: [_jsx(Text, { color: accent, bold: true, children: a(truncate(label ? `${session.status.stationName} · ${label}` : session.status.stationName, width)) }), height >= 12 ? _jsx(Text, { color: textMuted, children: a(truncate(`${starting ? 'Preparing' : 'Started'} ${formatElapsed(session.status.startedAt)} ago · scheduled ${formatWhen(new Date(session.status.scheduledAt))}`, width)) }) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: width >= 58 ? 'row' : 'column', alignItems: "center", children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ENTER WAIT FOR STATION ' : ' ENTER KEEP PLAYING ') }), width >= 58 ? _jsx(Text, { children: " " }) : null, _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(` SPACE SNOOZE ${snoozeMinutes} MIN `) })] }), height >= 14 ? _jsx(Text, { color: textMuted, children: a(starting ? 'Space snoozes before playback · b return' : 'Enter opens Playing · Space stops the radio until snooze ends · b return') }) : null, sessions.length > 1 && height >= 15 ? _jsx(Text, { color: textMuted, children: a(`↑/↓ choose active alarm · ${selected + 1}/${sessions.length}`) }) : null] }) : _jsx(Text, { color: textMuted, children: "The alarm session ended. Press b to return." })] });
85
86
  }
86
87
  function editorRows(draft) {
87
88
  const next = draftNextOccurrence(draft);
@@ -4,16 +4,19 @@ 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 { displayWidth, truncate } from '../format.js';
8
- export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
7
+ import { displayWidth } from '../format.js';
8
+ import { AdaptiveMarquee } from '../components/AdaptiveMarquee.js';
9
+ export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width, reduceMotion = false }) {
9
10
  const window = visibleWindow(countries, selected, pageSize);
10
11
  const rowWidth = Math.max(24, width - 2);
11
12
  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));
13
+ // Keep the metadata column fixed while the viewport scrolls. Forty-two cells
14
+ // accommodates long directory names such as Lao People's Democratic Republic
15
+ // plus its country code, while narrow terminals still receive a bounded row.
16
+ const countryWidth = Math.min(42, Math.max(8, rowWidth - countWidth - 4));
14
17
  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
18
  const code = ` (${country.code})`;
16
19
  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"] }) })] }));
20
+ 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: _jsx(AdaptiveMarquee, { text: country.name, width: nameWidth, active: active, reduceMotion: reduceMotion }) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
18
21
  } })] })) : null] }));
19
22
  }