@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
@@ -0,0 +1,163 @@
1
+ import { withExternalResponse } from '../platform/network.js';
2
+ import { safeExternalHttpUrl, sanitizeTerminalText } from '../safety.js';
3
+ import { stationFromUrl } from '../playlists/playlist.js';
4
+ class DefiniteStreamError extends Error {
5
+ }
6
+ /**
7
+ * Build a custom station and enrich it from bounded HTTP/ICY response headers.
8
+ * The submitted URL is intentionally retained because a followed redirect may
9
+ * contain a short-lived playback token.
10
+ */
11
+ export async function importStreamUrl(input, requestedName, options = {}) {
12
+ const url = validatedStreamUrl(input);
13
+ const explicitName = sanitizeTerminalText(requestedName);
14
+ try {
15
+ let metadata = await probeStreamHeaders(url, options);
16
+ const listing = await probeIHeartListing(url, options).catch(() => undefined);
17
+ if (listing)
18
+ metadata = { ...metadata, ...listing };
19
+ const name = explicitName ?? metadata.name ?? fallbackStationName(url);
20
+ return {
21
+ station: {
22
+ ...stationFromUrl(url, name),
23
+ ...(metadata.homepage ? { homepage: metadata.homepage } : {}),
24
+ ...(metadata.codec ? { codec: metadata.codec } : {}),
25
+ ...(metadata.bitrate ? { bitrate: metadata.bitrate } : {}),
26
+ ...(metadata.hls !== undefined ? { hls: metadata.hls } : {})
27
+ },
28
+ identified: Boolean(metadata.name),
29
+ ...(!metadata.name && !explicitName
30
+ ? { warning: `The stream did not publish a station name; saved as ${name}.` }
31
+ : {})
32
+ };
33
+ }
34
+ catch (error) {
35
+ if (error instanceof DefiniteStreamError)
36
+ throw error;
37
+ const name = explicitName ?? fallbackStationName(url);
38
+ return {
39
+ station: stationFromUrl(url, name),
40
+ identified: false,
41
+ warning: `Could not read stream metadata; saved as ${name}.`
42
+ };
43
+ }
44
+ }
45
+ async function probeStreamHeaders(url, options) {
46
+ return withExternalResponse(url, {
47
+ timeoutMs: options.timeoutMs ?? 7_000,
48
+ fetchImpl: options.fetchImpl,
49
+ init: {
50
+ method: 'GET',
51
+ redirect: 'follow',
52
+ headers: {
53
+ accept: 'audio/*, application/ogg, application/vnd.apple.mpegurl, application/x-mpegurl, */*;q=0.1',
54
+ 'icy-metadata': '1'
55
+ }
56
+ }
57
+ }, response => {
58
+ if (!response.ok) {
59
+ throw new DefiniteStreamError(`The stream returned HTTP ${response.status}.`);
60
+ }
61
+ const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
62
+ if (contentType === 'text/html' || contentType === 'application/json') {
63
+ throw new DefiniteStreamError('That URL points to a webpage or API response, not a direct radio stream.');
64
+ }
65
+ const icyName = sanitizeTerminalText(response.headers.get('icy-name'));
66
+ const icyDescription = sanitizeTerminalText(response.headers.get('icy-description'));
67
+ const homepage = safeOptionalHttpUrl(response.headers.get('icy-url'));
68
+ const bitrate = parseBitrate(response.headers.get('icy-br'), response.headers.get('icy-audio-info'));
69
+ const codec = codecFromContentType(contentType);
70
+ const hls = isHls(contentType, url);
71
+ return {
72
+ ...(icyName || icyDescription ? { name: icyName ?? icyDescription } : {}),
73
+ ...(homepage ? { homepage } : {}),
74
+ ...(codec ? { codec } : {}),
75
+ ...(bitrate ? { bitrate } : {}),
76
+ ...(hls ? { hls: true } : {})
77
+ };
78
+ });
79
+ }
80
+ /** iHeart stream headers are sometimes unnamed, but their stable numeric stream
81
+ * id resolves to a canonical public station path without downloading the page. */
82
+ async function probeIHeartListing(url, options) {
83
+ const parsed = new URL(url);
84
+ if (!/(?:^|\.)ihrhls\.com$/i.test(parsed.hostname))
85
+ return undefined;
86
+ const stationId = /\/zc(\d+)(?:\/|$)/i.exec(parsed.pathname)?.[1]
87
+ ?? /\/(\d+)_icy(?:\/|$)/i.exec(parsed.pathname)?.[1];
88
+ if (!stationId)
89
+ return undefined;
90
+ return withExternalResponse(`https://www.iheart.com/live/${stationId}/`, {
91
+ timeoutMs: Math.min(options.timeoutMs ?? 7_000, 4_000),
92
+ fetchImpl: options.fetchImpl,
93
+ init: { method: 'HEAD', redirect: 'manual' }
94
+ }, response => {
95
+ const location = response.headers.get('location');
96
+ const slug = location && new RegExp(`^/live/(.+)-${stationId}/?$`, 'i').exec(location)?.[1];
97
+ if (!slug)
98
+ return undefined;
99
+ const name = stationNameFromSlug(slug);
100
+ return name ? { name, homepage: `https://www.iheart.com${location}` } : undefined;
101
+ });
102
+ }
103
+ function validatedStreamUrl(input) {
104
+ const cleaned = safeExternalHttpUrl(input);
105
+ if (!cleaned)
106
+ throw new Error('Import requires a direct HTTP(S) stream URL.');
107
+ const parsed = new URL(cleaned);
108
+ if (parsed.username || parsed.password)
109
+ throw new Error('Stream URLs containing credentials are not supported.');
110
+ return cleaned;
111
+ }
112
+ function safeOptionalHttpUrl(input) {
113
+ return input ? safeExternalHttpUrl(input) ?? undefined : undefined;
114
+ }
115
+ function parseBitrate(icyBitrate, audioInfo) {
116
+ const raw = icyBitrate?.match(/\d+/)?.[0] ?? audioInfo?.match(/(?:^|;)\s*bitrate=(\d+)/i)?.[1];
117
+ const bitrate = Number(raw);
118
+ return Number.isFinite(bitrate) && bitrate > 0 ? bitrate : undefined;
119
+ }
120
+ function codecFromContentType(contentType) {
121
+ if (!contentType)
122
+ return undefined;
123
+ if (contentType === 'audio/mpeg' || contentType === 'audio/mp3')
124
+ return 'MP3';
125
+ if (contentType === 'audio/aac' || contentType === 'audio/aacp')
126
+ return 'AAC';
127
+ if (contentType === 'audio/ogg' || contentType === 'application/ogg')
128
+ return 'OGG';
129
+ if (contentType === 'audio/flac' || contentType === 'audio/x-flac')
130
+ return 'FLAC';
131
+ if (contentType.includes('mpegurl'))
132
+ return 'HLS';
133
+ return undefined;
134
+ }
135
+ function isHls(contentType, url) {
136
+ return Boolean(contentType?.includes('mpegurl') || /\.m3u8(?:$|[?#])/i.test(url));
137
+ }
138
+ function fallbackStationName(url) {
139
+ const parsed = new URL(url);
140
+ const segment = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '')
141
+ .replace(/\.(?:aac|flac|m3u8?|mp3|ogg|opus)$/i, '')
142
+ .replace(/[-_]+/g, ' ')
143
+ .trim();
144
+ return sanitizeTerminalText(segment) ?? parsed.hostname.replace(/^www\./i, '');
145
+ }
146
+ function stationNameFromSlug(slug) {
147
+ const tokens = slug.split('-').filter(Boolean);
148
+ const final = tokens.at(-1);
149
+ const previous = tokens.at(-2);
150
+ if (final && previous && /^\d$/.test(final) && /^\d{2,3}$/.test(previous)) {
151
+ tokens.splice(-2, 2, `${previous}.${final}`);
152
+ }
153
+ return sanitizeTerminalText(tokens.map(token => {
154
+ if (/^(?:am|fm|hd\d*)$/i.test(token))
155
+ return token.toUpperCase();
156
+ if (/^jamn$/i.test(token))
157
+ return "JAM'N";
158
+ if (/^\d{3,4}$/.test(token) && Number(token) >= 881 && Number(token) <= 1079) {
159
+ return `${token.slice(0, -1)}.${token.slice(-1)}`;
160
+ }
161
+ return token.charAt(0).toUpperCase() + token.slice(1);
162
+ }).join(' '));
163
+ }
@@ -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, 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, } = 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,10 +33,10 @@ 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({
@@ -54,7 +54,8 @@ function AdaptiveContentBody(props) {
54
54
  selected,
55
55
  width,
56
56
  mode,
57
- ascii
57
+ ascii,
58
+ airPlaySupported
58
59
  });
59
60
  const empty = adaptiveEmptyState({
60
61
  screen,
@@ -67,7 +68,7 @@ function AdaptiveContentBody(props) {
67
68
  stationTitle,
68
69
  stationError: props.stationError
69
70
  });
70
- 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 }));
71
72
  }
72
73
  if (screen === 'stats') {
73
74
  const stats = computeListeningStats(library.activity.sessions);
@@ -86,7 +87,7 @@ function AdaptiveContentBody(props) {
86
87
  }
87
88
  if (screen === 'airplay-code') {
88
89
  const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.';
89
- 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 }));
90
91
  }
91
92
  const rows = adaptiveRows({
92
93
  screen,
@@ -103,7 +104,8 @@ function AdaptiveContentBody(props) {
103
104
  selected,
104
105
  width,
105
106
  mode,
106
- ascii
107
+ ascii,
108
+ airPlaySupported
107
109
  });
108
110
  const empty = adaptiveEmptyState({
109
111
  screen,
@@ -116,11 +118,12 @@ function AdaptiveContentBody(props) {
116
118
  stationTitle,
117
119
  stationError: props.stationError
118
120
  });
119
- 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 })) }));
120
122
  }
121
123
  function AdaptiveExplore({ mode, width, height, theme, stations, selected, favorites, cursor, loading, error, ascii, reduceMotion }) {
122
124
  const accent = themeAccent(theme);
123
125
  const { headerRows, headerGap, bodyRows } = adaptiveExploreFrameMetrics(mode, height);
126
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
124
127
  const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows));
125
128
  const marker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
126
129
  const map = React.useMemo(() => buildCosmoWorldMap(layout.mapColumns, layout.mapRows, marker), [layout.mapColumns, layout.mapRows, marker]);
@@ -134,10 +137,10 @@ function AdaptiveExplore({ mode, width, height, theme, stations, selected, favor
134
137
  ? [{ key: 'error', label: error }]
135
138
  : [{ key: 'empty', label: 'No stations near this point.' }];
136
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 }));
137
- 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] }));
138
141
  }
139
142
  function AdaptiveCosmoMap({ rows, width, height, offsetX, theme, ascii }) {
140
- 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) => {
141
144
  const chunks = [];
142
145
  for (const cell of row.cells) {
143
146
  const previous = chunks.at(-1);
@@ -166,11 +169,12 @@ function adaptiveMapColor(kind, theme) {
166
169
  function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
167
170
  const accent = themeAccent(theme);
168
171
  const { ascii } = useDisplay();
172
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
169
173
  const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
170
174
  const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
171
175
  const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
172
176
  const { bodyRows, gapRows } = adaptiveFrameMetrics(mode, height);
173
- 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] }));
174
178
  }
175
179
  function adaptiveFrameMetrics(mode, height) {
176
180
  const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
@@ -182,6 +186,8 @@ function adaptiveFrameMetrics(mode, height) {
182
186
  }
183
187
  function AdaptiveHome({ mode, selected, height, width, theme, library }) {
184
188
  const gapRows = height >= 5 ? 1 : 0;
189
+ const { ascii } = useDisplay();
190
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
185
191
  const showSummary = mode === 'compact' && height >= 10;
186
192
  const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
187
193
  const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
@@ -191,8 +197,8 @@ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
191
197
  const absoluteIndex = window.start + offset;
192
198
  const active = absoluteIndex === selectedIndex;
193
199
  const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : '';
194
- return (_jsxs(Text, { color: active ? accent : undefined, bold: active, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", truncate(`${item.label}${detail}`, Math.max(1, width - 4))] }, item.screen));
195
- }) }), 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] }));
196
202
  }
197
203
  function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion }) {
198
204
  const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
@@ -205,35 +211,37 @@ function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion })
205
211
  const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
206
212
  const text = `${row.label}${detail}`;
207
213
  const favoriteWidth = row.favoriteGlyph ? 2 : 0;
208
- 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));
209
215
  }) }));
210
216
  }
211
217
  function StaticRows({ rows, width, theme }) {
212
218
  const accent = themeAccent(theme);
213
- 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))) }));
214
221
  }
215
222
  function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
216
223
  const pulse = useReceiverPulse();
224
+ const { screenReader } = useDisplay();
217
225
  const accent = themeAccent(theme);
218
226
  const stationName = station?.name ?? 'No station tuned';
219
- const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title);
227
+ const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
220
228
  const headerRows = mode === 'compact' ? 2 : 1;
221
229
  const metadataRows = showMetadata ? 1 : 0;
222
230
  const gapRows = height >= 5 ? 1 : 0;
223
231
  const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
224
232
  const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
225
- 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');
226
234
  const header = mode === 'micro'
227
235
  ? `${stationName} · ${playback.state}`
228
236
  : 'RADIOCLI Now playing';
229
237
  const status = `${stationName} ${playback.state}${playback.muted ? ' · muted' : ` · vol ${playback.volume}`}`;
230
- 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'
231
239
  ? _jsx(AdaptiveMarquee, { text: header, width: width, active: true, reduceMotion: reduceMotion })
232
- : 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
233
241
  ? renderAdaptiveSegments(row.segments, ascii)
234
242
  : ascii
235
243
  ? toAsciiSafe(row.text)
236
- : 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] }));
237
245
  }
238
246
  function renderAdaptiveSegments(segments, ascii) {
239
247
  let offset = 0;
@@ -246,12 +254,13 @@ function renderAdaptiveSegments(segments, ascii) {
246
254
  function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
247
255
  const accent = themeAccent(theme);
248
256
  const { panel: panelBackground } = useDisplay();
257
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
249
258
  const fieldText = query || 'station, genre, or place';
250
259
  const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
251
260
  const fieldRows = height >= 4 ? 3 : 1;
252
261
  const gapRows = height >= 6 ? 1 : 0;
253
262
  const listRows = Math.max(0, height - 1 - fieldRows - gapRows * 2);
254
- 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] }));
255
264
  }
256
265
  function adaptiveRows(input) {
257
266
  const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
@@ -265,11 +274,11 @@ function adaptiveRows(input) {
265
274
  }
266
275
  if (screen === 'settings') {
267
276
  const pageItems = settingsItemsForPage(input.settingsPage);
268
- const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion));
277
+ const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
269
278
  const labelWidth = pairedColumnWidth(labels, width, mode);
270
279
  return pageItems.map((item, index) => ({
271
280
  key: item,
272
- label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth),
281
+ label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
273
282
  detail: input.settingsPage === 'root'
274
283
  ? adaptiveSettingsRootValue(item)
275
284
  : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),