@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
@@ -5,7 +5,7 @@ import { computeListeningStats } from '../activity/stats.js';
5
5
  import { playbackBackendLabel } from '../player/backend-install.js';
6
6
  import { visibleWindow } from './list-window.js';
7
7
  import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
8
- import { homeItems, settingsItems, settingsSectionFor } from './screen-items.js';
8
+ import { homeItems, settingsGroup, settingsGroups, settingsItemsForPage } from './screen-items.js';
9
9
  import { screenTitle } from './screen-meta.js';
10
10
  import { keyHelpSections, commandHelp } from './help-content.js';
11
11
  import { settingLabel, settingValue } from './screens/SettingsScreen.js';
@@ -23,9 +23,9 @@ export function AdaptiveContent(props) {
23
23
  return _jsx(AdaptiveContentBody, { ...props });
24
24
  }
25
25
  function AdaptiveContentBody(props) {
26
- const { mode, screen, selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, favoriteKeys, stationTitle, } = props;
26
+ const { mode, screen, settingsPage = 'root', selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, appVersion, favoriteKeys, stationTitle, airPlaySupported = true, } = props;
27
27
  const accent = themeAccent(theme);
28
- const { ascii } = useDisplay();
28
+ const { ascii, reduceMotion } = useDisplay();
29
29
  const { bodyRows } = adaptiveFrameMetrics(mode, height);
30
30
  const title = screenTitle(screen);
31
31
  const status = adaptiveStatus(props);
@@ -33,14 +33,15 @@ function AdaptiveContentBody(props) {
33
33
  return (_jsx(AdaptiveHome, { mode: mode, selected: selected, height: height, width: width, theme: theme, library: library }));
34
34
  }
35
35
  if (screen === 'now-playing') {
36
- return (_jsx(AdaptiveNowPlaying, { mode: mode, station: playingStation, playback: playback, metadata: nowPlaying, width: width, height: height, ascii: ascii, theme: theme, receiverStyle: library.settings.receiverStyle, reduceMotion: Boolean(library.settings.reduceMotion) }));
36
+ return (_jsx(AdaptiveNowPlaying, { mode: mode, station: playingStation, playback: playback, metadata: nowPlaying, width: width, height: height, ascii: ascii, theme: theme, receiverStyle: library.settings.receiverStyle, reduceMotion: reduceMotion }));
37
37
  }
38
38
  if (screen === 'explore') {
39
- return (_jsx(AdaptiveExplore, { mode: mode, width: width, height: height, theme: theme, stations: stations, selected: selected, favorites: favoriteKeys, cursor: exploreCursor, loading: loadingStations, error: props.stationError, ascii: ascii, reduceMotion: Boolean(library.settings.reduceMotion) }));
39
+ return (_jsx(AdaptiveExplore, { mode: mode, width: width, height: height, theme: theme, stations: stations, selected: selected, favorites: favoriteKeys, cursor: exploreCursor, loading: loadingStations, error: props.stationError, ascii: ascii, reduceMotion: reduceMotion }));
40
40
  }
41
41
  if (screen === 'search') {
42
42
  const rows = adaptiveRows({
43
43
  screen,
44
+ settingsPage,
44
45
  stations,
45
46
  countries,
46
47
  airPlayDevices,
@@ -48,11 +49,13 @@ function AdaptiveContentBody(props) {
48
49
  diagnostics,
49
50
  backends,
50
51
  updateCheck,
52
+ appVersion,
51
53
  favoriteKeys,
52
54
  selected,
53
55
  width,
54
56
  mode,
55
- ascii
57
+ ascii,
58
+ airPlaySupported
56
59
  });
57
60
  const empty = adaptiveEmptyState({
58
61
  screen,
@@ -65,7 +68,7 @@ function AdaptiveContentBody(props) {
65
68
  stationTitle,
66
69
  stationError: props.stationError
67
70
  });
68
- return (_jsx(AdaptiveSearch, { mode: mode, query: searchQuery, editing: editingSearch, loading: loadingStations, rows: rows, empty: empty, selected: selected, height: height, width: width, theme: theme, ascii: ascii, reduceMotion: Boolean(library.settings.reduceMotion) }));
71
+ return (_jsx(AdaptiveSearch, { mode: mode, query: searchQuery, editing: editingSearch, loading: loadingStations, rows: rows, empty: empty, selected: selected, height: height, width: width, theme: theme, ascii: ascii, reduceMotion: reduceMotion }));
69
72
  }
70
73
  if (screen === 'stats') {
71
74
  const stats = computeListeningStats(library.activity.sessions);
@@ -84,10 +87,11 @@ function AdaptiveContentBody(props) {
84
87
  }
85
88
  if (screen === 'airplay-code') {
86
89
  const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.';
87
- return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: bodyRows > 0 ? _jsx(Text, { color: airPlayCode ? accent : textMuted, children: truncate(prompt, width) }) : null }));
90
+ return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: bodyRows > 0 ? _jsx(Text, { color: airPlayCode ? accent : textMuted, children: ascii ? toAsciiSafe(truncate(prompt, width)) : truncate(prompt, width) }) : null }));
88
91
  }
89
92
  const rows = adaptiveRows({
90
93
  screen,
94
+ settingsPage,
91
95
  stations,
92
96
  countries,
93
97
  airPlayDevices,
@@ -95,11 +99,13 @@ function AdaptiveContentBody(props) {
95
99
  diagnostics,
96
100
  backends,
97
101
  updateCheck,
102
+ appVersion,
98
103
  favoriteKeys,
99
104
  selected,
100
105
  width,
101
106
  mode,
102
- ascii
107
+ ascii,
108
+ airPlaySupported
103
109
  });
104
110
  const empty = adaptiveEmptyState({
105
111
  screen,
@@ -112,11 +118,12 @@ function AdaptiveContentBody(props) {
112
118
  stationTitle,
113
119
  stationError: props.stationError
114
120
  });
115
- return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: bodyRows, width: width, theme: theme, reduceMotion: Boolean(library.settings.reduceMotion) })) : (_jsx(StaticRows, { rows: empty.slice(0, bodyRows), width: width, theme: theme })) }));
121
+ return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: bodyRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, bodyRows), width: width, theme: theme })) }));
116
122
  }
117
123
  function AdaptiveExplore({ mode, width, height, theme, stations, selected, favorites, cursor, loading, error, ascii, reduceMotion }) {
118
124
  const accent = themeAccent(theme);
119
125
  const { headerRows, headerGap, bodyRows } = adaptiveExploreFrameMetrics(mode, height);
126
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
120
127
  const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows));
121
128
  const marker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
122
129
  const map = React.useMemo(() => buildCosmoWorldMap(layout.mapColumns, layout.mapRows, marker), [layout.mapColumns, layout.mapRows, marker]);
@@ -130,10 +137,10 @@ function AdaptiveExplore({ mode, width, height, theme, stations, selected, favor
130
137
  ? [{ key: 'error', label: error }]
131
138
  : [{ key: 'empty', label: 'No stations near this point.' }];
132
139
  const list = (_jsx(AdaptiveList, { rows: stationRows.length > 0 ? stationRows : emptyRows, selected: stationRows.length > 0 ? selected : 0, pageSize: layout.listRows, width: layout.listWidth, theme: theme, reduceMotion: reduceMotion }));
133
- return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: truncate(title, width) }), headerRows > 1 ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, headerGap ? _jsx(Box, { height: headerGap, flexShrink: 0 }) : null, bodyRows > 0 ? layout.split ? (_jsxs(Box, { flexDirection: "row", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { width: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", width: layout.listWidth, height: layout.listRows, overflow: "hidden", children: list })] })) : (_jsxs(Box, { flexDirection: "column", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { height: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: layout.listRows, width: layout.listWidth, overflow: "hidden", children: list })] })) : null] }));
140
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, "aria-label": title, children: a(truncate(title, width)) }), headerRows > 1 ? _jsx(Text, { color: textMuted, "aria-label": status, children: a(truncate(status, width)) }) : null, headerGap ? _jsx(Box, { height: headerGap, flexShrink: 0 }) : null, bodyRows > 0 ? layout.split ? (_jsxs(Box, { flexDirection: "row", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { width: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", width: layout.listWidth, height: layout.listRows, overflow: "hidden", children: list })] })) : (_jsxs(Box, { flexDirection: "column", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { height: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: layout.listRows, width: layout.listWidth, overflow: "hidden", children: list })] })) : null] }));
134
141
  }
135
142
  function AdaptiveCosmoMap({ rows, width, height, offsetX, theme, ascii }) {
136
- return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, children: rows.map((row, rowIndex) => {
143
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, "aria-hidden": true, children: rows.map((row, rowIndex) => {
137
144
  const chunks = [];
138
145
  for (const cell of row.cells) {
139
146
  const previous = chunks.at(-1);
@@ -162,11 +169,12 @@ function adaptiveMapColor(kind, theme) {
162
169
  function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
163
170
  const accent = themeAccent(theme);
164
171
  const { ascii } = useDisplay();
172
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
165
173
  const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
166
174
  const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
167
175
  const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
168
176
  const { bodyRows, gapRows } = adaptiveFrameMetrics(mode, height);
169
- return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsxs(Text, { color: accent, bold: true, children: [titleText, _jsx(Text, { color: textDim, children: ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : '' })] }), mode === 'compact' && height >= 4 ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", flexShrink: 0, children: children }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
177
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsxs(Text, { color: accent, bold: true, "aria-label": `${titlePrefix}${title}${status ? `. ${status}` : ''}`, children: [a(titleText), _jsx(Text, { color: textDim, "aria-hidden": true, children: ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : '' })] }), mode === 'compact' && height >= 4 ? _jsx(Text, { color: textMuted, "aria-label": status, children: a(truncate(status, width)) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", flexShrink: 0, children: children }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
170
178
  }
171
179
  function adaptiveFrameMetrics(mode, height) {
172
180
  const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
@@ -178,6 +186,8 @@ function adaptiveFrameMetrics(mode, height) {
178
186
  }
179
187
  function AdaptiveHome({ mode, selected, height, width, theme, library }) {
180
188
  const gapRows = height >= 5 ? 1 : 0;
189
+ const { ascii } = useDisplay();
190
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
181
191
  const showSummary = mode === 'compact' && height >= 10;
182
192
  const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
183
193
  const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
@@ -187,8 +197,8 @@ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
187
197
  const absoluteIndex = window.start + offset;
188
198
  const active = absoluteIndex === selectedIndex;
189
199
  const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : '';
190
- return (_jsxs(Text, { color: active ? accent : undefined, bold: active, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", truncate(`${item.label}${detail}`, Math.max(1, width - 4))] }, item.screen));
191
- }) }), showSummary ? (_jsx(Text, { color: textMuted, children: truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
200
+ return (_jsxs(Text, { color: active ? accent : undefined, bold: active, "aria-label": `${active ? 'Selected: ' : ''}${absoluteIndex + 1}. ${item.label}. ${item.detail}`, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", a(truncate(`${item.label}${detail}`, Math.max(1, width - 4)))] }, item.screen));
201
+ }) }), showSummary ? (_jsx(Text, { color: textMuted, children: a(truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width)) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
192
202
  }
193
203
  function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion }) {
194
204
  const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
@@ -201,35 +211,37 @@ function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion })
201
211
  const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
202
212
  const text = `${row.label}${detail}`;
203
213
  const favoriteWidth = row.favoriteGlyph ? 2 : 0;
204
- return (_jsxs(Text, { color: active ? accent : row.heading ? textMuted : undefined, bold: active || row.heading, children: [prefix, _jsx(AdaptiveMarquee, { text: text, width: Math.max(0, width - 2 - favoriteWidth), active: active && Boolean(row.marquee), reduceMotion: reduceMotion }), row.favoriteGlyph ? _jsxs(Text, { color: "yellow", children: [" ", row.favoriteGlyph] }) : null] }, row.key));
214
+ return (_jsxs(Text, { color: active ? accent : row.heading ? textMuted : undefined, bold: active || row.heading, "aria-label": `${active ? 'Selected: ' : ''}${text}${row.favoriteGlyph ? '. Favorite' : ''}`, children: [prefix, _jsx(AdaptiveMarquee, { text: text, width: Math.max(0, width - 2 - favoriteWidth), active: active && Boolean(row.marquee), reduceMotion: reduceMotion }), row.favoriteGlyph ? _jsxs(Text, { color: "yellow", children: [" ", row.favoriteGlyph] }) : null] }, row.key));
205
215
  }) }));
206
216
  }
207
217
  function StaticRows({ rows, width, theme }) {
208
218
  const accent = themeAccent(theme);
209
- return (_jsx(Box, { flexDirection: "column", children: rows.map(row => (_jsxs(Text, { color: row.heading ? textMuted : undefined, bold: row.heading, children: [truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width), row.key === 'activity' ? _jsx(Text, { color: accent }) : null] }, row.key))) }));
219
+ const { ascii } = useDisplay();
220
+ return (_jsx(Box, { flexDirection: "column", children: rows.map(row => (_jsxs(Text, { color: row.heading ? textMuted : undefined, bold: row.heading, "aria-label": row.detail ? `${row.label}: ${row.detail}` : row.label, children: [ascii ? toAsciiSafe(truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)) : truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width), row.key === 'activity' ? _jsx(Text, { color: accent }) : null] }, row.key))) }));
210
221
  }
211
222
  function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
212
223
  const pulse = useReceiverPulse();
224
+ const { screenReader } = useDisplay();
213
225
  const accent = themeAccent(theme);
214
226
  const stationName = station?.name ?? 'No station tuned';
215
- const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title);
227
+ const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
216
228
  const headerRows = mode === 'compact' ? 2 : 1;
217
229
  const metadataRows = showMetadata ? 1 : 0;
218
230
  const gapRows = height >= 5 ? 1 : 0;
219
231
  const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
220
232
  const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
221
- const visualRows = buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
233
+ const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
222
234
  const header = mode === 'micro'
223
235
  ? `${stationName} · ${playback.state}`
224
236
  : 'RADIOCLI Now playing';
225
237
  const status = `${stationName} ${playback.state}${playback.muted ? ' · muted' : ` · vol ${playback.volume}`}`;
226
- return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro'
238
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, "aria-label": mode === 'micro' ? `Now playing. Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}` : undefined, children: mode === 'micro'
227
239
  ? _jsx(AdaptiveMarquee, { text: header, width: width, active: true, reduceMotion: reduceMotion })
228
- : truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
240
+ : truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, "aria-label": `Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, !screenReader ? _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
229
241
  ? renderAdaptiveSegments(row.segments, ascii)
230
242
  : ascii
231
243
  ? toAsciiSafe(row.text)
232
- : row.text }, index))) }), showMetadata ? (_jsx(Text, { color: accent, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
244
+ : row.text }, index))) }) : null, showMetadata ? (_jsx(Text, { color: accent, "aria-label": `Track: ${metadata?.title ?? ''}`, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
233
245
  }
234
246
  function renderAdaptiveSegments(segments, ascii) {
235
247
  let offset = 0;
@@ -242,12 +254,13 @@ function renderAdaptiveSegments(segments, ascii) {
242
254
  function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
243
255
  const accent = themeAccent(theme);
244
256
  const { panel: panelBackground } = useDisplay();
257
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
245
258
  const fieldText = query || 'station, genre, or place';
246
259
  const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
247
260
  const fieldRows = height >= 4 ? 3 : 1;
248
261
  const gapRows = height >= 6 ? 1 : 0;
249
262
  const listRows = Math.max(0, height - 1 - fieldRows - gapRows * 2);
250
- return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro' ? 'SEARCH' : 'RADIOCLI Search' }), fieldRows === 3 ? (_jsxs(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing || loading ? accent : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, height: 3, flexShrink: 0, children: [_jsx(Text, { color: editing || loading ? accent : textMuted, children: prefix }), _jsx(Text, { color: query ? accent : textMuted, children: truncate(fieldText, Math.max(1, width - 5)) })] })) : (_jsx(Text, { color: query ? accent : textMuted, children: truncate(`[${prefix}${fieldText}]`, width) })), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: listRows, overflow: "hidden", flexShrink: 0, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: listRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, listRows), width: width, theme: theme })) }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
263
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro' ? 'SEARCH' : 'RADIOCLI Search' }), fieldRows === 3 ? (_jsxs(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing || loading ? accent : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, height: 3, flexShrink: 0, children: [_jsx(Text, { color: editing || loading ? accent : textMuted, children: a(prefix) }), _jsx(Text, { color: query ? accent : textMuted, children: a(truncate(fieldText, Math.max(1, width - 5))) })] })) : (_jsx(Text, { color: query ? accent : textMuted, children: a(truncate(`[${prefix}${fieldText}]`, width)) })), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: listRows, overflow: "hidden", flexShrink: 0, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: listRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, listRows), width: width, theme: theme })) }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
251
264
  }
252
265
  function adaptiveRows(input) {
253
266
  const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
@@ -255,16 +268,20 @@ function adaptiveRows(input) {
255
268
  return homeItems.map(item => ({
256
269
  key: item.screen,
257
270
  label: item.label,
258
- detail: mode === 'compact' && width >= 52 ? item.detail : undefined
271
+ detail: mode === 'compact' && width >= 52 ? item.detail : undefined,
272
+ separator: ' '
259
273
  }));
260
274
  }
261
275
  if (screen === 'settings') {
262
- const labels = settingsItems.map(item => settingLabel(item, updateCheck));
276
+ const pageItems = settingsItemsForPage(input.settingsPage);
277
+ const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
263
278
  const labelWidth = pairedColumnWidth(labels, width, mode);
264
- return settingsItems.map((item, index) => ({
279
+ return pageItems.map((item, index) => ({
265
280
  key: item,
266
- label: padDisplayEnd(truncate(settingLabel(item, updateCheck), labelWidth), labelWidth),
267
- detail: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck),
281
+ label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
282
+ detail: input.settingsPage === 'root'
283
+ ? adaptiveSettingsRootValue(item)
284
+ : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
268
285
  separator: ' ',
269
286
  index
270
287
  }));
@@ -303,6 +320,10 @@ function adaptiveRows(input) {
303
320
  }
304
321
  return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
305
322
  }
323
+ function adaptiveSettingsRootValue(item) {
324
+ const group = settingsGroups.find(candidate => candidate.label === item);
325
+ return group ? `${group.items.length} settings ›` : undefined;
326
+ }
306
327
  function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
307
328
  return stations.map((station, index) => {
308
329
  const favorite = favoriteKeys.has(`${station.provider}:${station.id}`);
@@ -362,7 +383,7 @@ function adaptiveEmptyState(input) {
362
383
  return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
363
384
  }
364
385
  function adaptiveStatus(props) {
365
- const { screen, playback, playingStation, stations, countries, searchQuery, editingSearch, countryFilter, editingCountryFilter, library, filterLabel, selected } = props;
386
+ const { screen, playback, playingStation, stations, countries, searchQuery, editingSearch, countryFilter, editingCountryFilter, library, filterLabel } = props;
366
387
  if (screen === 'home') {
367
388
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported`;
368
389
  }
@@ -370,7 +391,10 @@ function adaptiveStatus(props) {
370
391
  return `${playbackBackendLabel(playback.backend)} · ${playback.state} · vol ${playback.volume}`;
371
392
  }
372
393
  if (screen === 'settings') {
373
- return `${settingsSectionFor(settingsItems[selected])} · Enter changes the selected setting`;
394
+ const group = settingsGroup(props.settingsPage ?? 'root');
395
+ return group
396
+ ? `${group.label} · Enter changes setting · b categories`
397
+ : 'Choose a category · Updates are available here';
374
398
  }
375
399
  if (screen === 'search') {
376
400
  const query = searchQuery || (editingSearch ? 'type to search' : 'press / to search');