@ciphore/radiocli 0.2.3 → 0.2.5

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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -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
+ }
@@ -5,9 +5,9 @@ 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, settingsGroup, settingsGroups, settingsItemsForPage } from './screen-items.js';
8
+ import { homeItems, settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from './screen-items.js';
9
9
  import { screenTitle } from './screen-meta.js';
10
- import { keyHelpSections, commandHelp } from './help-content.js';
10
+ import { keyHelpSections, commandHelpForAirPlay } from './help-content.js';
11
11
  import { settingLabel, settingValue } from './screens/SettingsScreen.js';
12
12
  import { exploreMapLand, mapMarker, panelBorder, textDim, textMuted, themeAccent } from './theme.js';
13
13
  import { panelBorderStyle, useDisplay } from './display-context.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, 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,9 +54,12 @@ function AdaptiveContentBody(props) {
54
54
  selected,
55
55
  width,
56
56
  mode,
57
- ascii
57
+ ascii,
58
+ airPlaySupported,
59
+ platform: props.platform
58
60
  });
59
61
  const empty = adaptiveEmptyState({
62
+ airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
60
63
  screen,
61
64
  searchQuery,
62
65
  editingSearch,
@@ -67,7 +70,7 @@ function AdaptiveContentBody(props) {
67
70
  stationTitle,
68
71
  stationError: props.stationError
69
72
  });
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) }));
73
+ 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
74
  }
72
75
  if (screen === 'stats') {
73
76
  const stats = computeListeningStats(library.activity.sessions);
@@ -86,7 +89,7 @@ function AdaptiveContentBody(props) {
86
89
  }
87
90
  if (screen === 'airplay-code') {
88
91
  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 }));
92
+ 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
93
  }
91
94
  const rows = adaptiveRows({
92
95
  screen,
@@ -103,9 +106,12 @@ function AdaptiveContentBody(props) {
103
106
  selected,
104
107
  width,
105
108
  mode,
106
- ascii
109
+ ascii,
110
+ airPlaySupported,
111
+ platform: props.platform
107
112
  });
108
113
  const empty = adaptiveEmptyState({
114
+ airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
109
115
  screen,
110
116
  searchQuery,
111
117
  editingSearch,
@@ -116,11 +122,12 @@ function AdaptiveContentBody(props) {
116
122
  stationTitle,
117
123
  stationError: props.stationError
118
124
  });
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 })) }));
125
+ 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
126
  }
121
127
  function AdaptiveExplore({ mode, width, height, theme, stations, selected, favorites, cursor, loading, error, ascii, reduceMotion }) {
122
128
  const accent = themeAccent(theme);
123
129
  const { headerRows, headerGap, bodyRows } = adaptiveExploreFrameMetrics(mode, height);
130
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
124
131
  const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows));
125
132
  const marker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
126
133
  const map = React.useMemo(() => buildCosmoWorldMap(layout.mapColumns, layout.mapRows, marker), [layout.mapColumns, layout.mapRows, marker]);
@@ -134,10 +141,10 @@ function AdaptiveExplore({ mode, width, height, theme, stations, selected, favor
134
141
  ? [{ key: 'error', label: error }]
135
142
  : [{ key: 'empty', label: 'No stations near this point.' }];
136
143
  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] }));
144
+ 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
145
  }
139
146
  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) => {
147
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, "aria-hidden": true, children: rows.map((row, rowIndex) => {
141
148
  const chunks = [];
142
149
  for (const cell of row.cells) {
143
150
  const previous = chunks.at(-1);
@@ -166,11 +173,12 @@ function adaptiveMapColor(kind, theme) {
166
173
  function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
167
174
  const accent = themeAccent(theme);
168
175
  const { ascii } = useDisplay();
176
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
169
177
  const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
170
178
  const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
171
179
  const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
172
180
  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] }));
181
+ 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
182
  }
175
183
  function adaptiveFrameMetrics(mode, height) {
176
184
  const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
@@ -182,6 +190,8 @@ function adaptiveFrameMetrics(mode, height) {
182
190
  }
183
191
  function AdaptiveHome({ mode, selected, height, width, theme, library }) {
184
192
  const gapRows = height >= 5 ? 1 : 0;
193
+ const { ascii } = useDisplay();
194
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
185
195
  const showSummary = mode === 'compact' && height >= 10;
186
196
  const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
187
197
  const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
@@ -191,8 +201,8 @@ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
191
201
  const absoluteIndex = window.start + offset;
192
202
  const active = absoluteIndex === selectedIndex;
193
203
  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] }));
204
+ 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));
205
+ }) }), 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
206
  }
197
207
  function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion }) {
198
208
  const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
@@ -205,35 +215,37 @@ function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion })
205
215
  const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
206
216
  const text = `${row.label}${detail}`;
207
217
  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));
218
+ 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
219
  }) }));
210
220
  }
211
221
  function StaticRows({ rows, width, theme }) {
212
222
  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))) }));
223
+ const { ascii } = useDisplay();
224
+ 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
225
  }
215
226
  function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
216
227
  const pulse = useReceiverPulse();
228
+ const { screenReader } = useDisplay();
217
229
  const accent = themeAccent(theme);
218
230
  const stationName = station?.name ?? 'No station tuned';
219
- const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title);
231
+ const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
220
232
  const headerRows = mode === 'compact' ? 2 : 1;
221
233
  const metadataRows = showMetadata ? 1 : 0;
222
234
  const gapRows = height >= 5 ? 1 : 0;
223
235
  const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
224
236
  const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
225
- const visualRows = buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
237
+ const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
226
238
  const header = mode === 'micro'
227
239
  ? `${stationName} · ${playback.state}`
228
240
  : 'RADIOCLI Now playing';
229
241
  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'
242
+ 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
243
  ? _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
244
+ : 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
245
  ? renderAdaptiveSegments(row.segments, ascii)
234
246
  : ascii
235
247
  ? 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] }));
248
+ : 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
249
  }
238
250
  function renderAdaptiveSegments(segments, ascii) {
239
251
  let offset = 0;
@@ -246,12 +258,13 @@ function renderAdaptiveSegments(segments, ascii) {
246
258
  function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
247
259
  const accent = themeAccent(theme);
248
260
  const { panel: panelBackground } = useDisplay();
261
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
249
262
  const fieldText = query || 'station, genre, or place';
250
263
  const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
251
264
  const fieldRows = height >= 4 ? 3 : 1;
252
265
  const gapRows = height >= 6 ? 1 : 0;
253
266
  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] }));
267
+ 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
268
  }
256
269
  function adaptiveRows(input) {
257
270
  const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
@@ -264,15 +277,15 @@ function adaptiveRows(input) {
264
277
  }));
265
278
  }
266
279
  if (screen === 'settings') {
267
- const pageItems = settingsItemsForPage(input.settingsPage);
268
- const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion));
280
+ const pageItems = settingsItemsForPage(input.settingsPage, input.airPlaySupported);
281
+ const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
269
282
  const labelWidth = pairedColumnWidth(labels, width, mode);
270
283
  return pageItems.map((item, index) => ({
271
284
  key: item,
272
- label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth),
285
+ label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
273
286
  detail: input.settingsPage === 'root'
274
- ? adaptiveSettingsRootValue(item)
275
- : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
287
+ ? adaptiveSettingsRootValue(item, input.airPlaySupported)
288
+ : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion, input.platform),
276
289
  separator: ' ',
277
290
  index
278
291
  }));
@@ -303,7 +316,7 @@ function adaptiveRows(input) {
303
316
  }
304
317
  }
305
318
  rows.push({ key: 'section-commands', label: 'Commands', heading: true });
306
- for (const command of commandHelp) {
319
+ for (const command of commandHelpForAirPlay(input.airPlaySupported)) {
307
320
  rows.push({ key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index });
308
321
  index += 1;
309
322
  }
@@ -311,8 +324,8 @@ function adaptiveRows(input) {
311
324
  }
312
325
  return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
313
326
  }
314
- function adaptiveSettingsRootValue(item) {
315
- const group = settingsGroups.find(candidate => candidate.label === item);
327
+ function adaptiveSettingsRootValue(item, airPlayVisible) {
328
+ const group = settingsGroupsForAirPlay(airPlayVisible).find(candidate => candidate.label === item);
316
329
  return group ? `${group.items.length} settings ›` : undefined;
317
330
  }
318
331
  function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
@@ -369,6 +382,8 @@ function adaptiveEmptyState(input) {
369
382
  return [{ key: 'error', label: stationError }, { key: 'hint', label: 'Try again later or reopen this view.' }];
370
383
  }
371
384
  if (screen === 'airplay-settings') {
385
+ if (input.airPlaySetupRequired)
386
+ return [{ key: 'empty', label: 'Setup required' }, { key: 'hint', label: 'radiocli setup' }, { key: 'option', label: '--airplay' }];
372
387
  return [{ key: 'empty', label: 'No AirPlay receivers found.' }, { key: 'hint', label: 'Press r to scan again.' }];
373
388
  }
374
389
  return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
@@ -406,6 +421,8 @@ function adaptiveStatus(props) {
406
421
  return playingStation ? playingStation.name : 'Receiver authentication';
407
422
  }
408
423
  if (screen === 'airplay-settings') {
424
+ if (props.platform === 'linux' && !props.backends.includes('airplay'))
425
+ return 'Setup required';
409
426
  return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`;
410
427
  }
411
428
  return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`;