@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
package/dist/ui/App.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize } from 'ink';
4
5
  import { ProviderManager } from '../providers/provider-manager.js';
5
6
  import { PlayerController } from '../player/player-controller.js';
6
- import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
+ import { airPlayMacOSOnlyMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
8
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
9
+ import { defaultAgentControlSettings } from '../types.js';
8
10
  import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
9
11
  import { DisplayContext, resolveDisplayMode } from './display-context.js';
10
- import { homeItems, settingsItems } from './screen-items.js';
12
+ import { homeItems, settingsItemsForPage } from './screen-items.js';
11
13
  import { AppContent } from './AppContent.js';
12
14
  import { TopTabs } from './components/TopTabs.js';
13
15
  import { computeTerminalLayout } from './layout.js';
14
- import { displayWidth, truncate } from './format.js';
16
+ import { truncate } from './format.js';
15
17
  import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
16
18
  import { balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText } from './page-footer.js';
17
19
  import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
@@ -19,20 +21,25 @@ import { useAppInput } from './use-app-input.js';
19
21
  import { useCommandExecutor } from './use-command-executor.js';
20
22
  import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
21
23
  import { isAirPlayBackendAvailable } from './airplay-settings.js';
24
+ import { networkPolicy } from '../platform/network.js';
22
25
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
23
26
  import { copyToClipboard, openExternal } from './system-actions.js';
27
+ import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
24
28
  import { appVersion } from '../version.js';
25
- import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
29
+ import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
26
30
  import { helpItemCount } from './help-content.js';
27
31
  import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
28
32
  import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
29
33
  import { useAlarmTui } from './use-alarm-tui.js';
30
34
  import { toAsciiSafe } from './ascii.js';
31
35
  import { registerTuiPresence } from '../alarms/tui-presence.js';
36
+ import { configureMcpIntegrations } from '../agent/mcp-install.js';
37
+ import { startRadioSession } from '../agent/session.js';
32
38
  import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
33
39
  import { ReceiverAnimationProvider } from './receiver-animation.js';
40
+ import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
34
41
  const LOADING_SPINNER_MS = 120;
35
- const VISUALIZER_MESSAGE_MS = 4500;
42
+ const TRANSIENT_NOTICE_MS = 4500;
36
43
  const LISTENING_HEARTBEAT_MS = 30_000;
37
44
  const COUNTRY_STATIONS_PAGE_SIZE = 120;
38
45
  const COUNTRY_STATIONS_LOAD_AHEAD = 12;
@@ -44,17 +51,20 @@ const settingToggleLabel = {
44
51
  transparentBackground: 'Transparent background',
45
52
  asciiMode: 'ASCII-safe display',
46
53
  reduceMotion: 'Reduce motion',
47
- mouseSupport: 'Mouse and trackpad scrolling'
54
+ mouseSupport: 'Mouse and trackpad scrolling',
55
+ automaticUpdateChecks: 'Automatic update checks'
48
56
  };
49
- export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview }) {
57
+ export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform }) {
50
58
  const { exit } = useApp();
51
59
  const { stdin } = useStdin();
52
60
  const { stdout } = useStdout();
53
61
  const { columns, rows } = useWindowSize();
62
+ const screenReader = useIsScreenReaderEnabled();
54
63
  const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
55
64
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
56
65
  const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
57
66
  const installedVersion = useMemo(() => appVersion(), []);
67
+ const airPlaySupported = isAirPlayPlatformSupported(platform);
58
68
  const [library, setLibrary] = useState(() => store.snapshot());
59
69
  const settingsRef = useRef(library.settings);
60
70
  settingsRef.current = library.settings;
@@ -64,7 +74,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
64
74
  const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
65
75
  const [screen, setScreen] = useState('home');
66
76
  const [selected, setSelected] = useState(0);
77
+ const [settingsPage, setSettingsPage] = useState('root');
67
78
  const [message, setMessage] = useState(null);
79
+ const [persistenceWarning, setPersistenceWarning] = useState(null);
80
+ const [presenceWarning, setPresenceWarning] = useState(null);
68
81
  const [footerMessage, setFooterMessage] = useState(null);
69
82
  const [countries, setCountries] = useState([]);
70
83
  const [countryFilter, setCountryFilter] = useState('');
@@ -88,8 +101,27 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
88
101
  const [sleepUntil, setSleepUntil] = useState(null);
89
102
  const [showDiagnostics, setShowDiagnostics] = useState(false);
90
103
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
104
+ const reportActionError = useCallback((error) => {
105
+ setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
106
+ }, []);
107
+ // Optional history cannot change the result of playback or navigation. Keep
108
+ // its warning until an explicit save succeeds; a no-op checkpoint proves nothing.
109
+ const persistLibrary = useCallback((write) => {
110
+ try {
111
+ const nextLibrary = write();
112
+ setLibrary(nextLibrary);
113
+ return nextLibrary;
114
+ }
115
+ catch (error) {
116
+ const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error));
117
+ setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`);
118
+ return undefined;
119
+ }
120
+ }, []);
91
121
  const announcedUpdateRef = useRef(false);
122
+ const automaticUpdateCheckStartedRef = useRef(false);
92
123
  const installingUpdateRef = useRef(false);
124
+ const configuringMcpRef = useRef(false);
93
125
  const displayStationsRef = useRef([]);
94
126
  const playbackQueueRef = useRef(null);
95
127
  const lastRawTransportAtRef = useRef(0);
@@ -99,6 +131,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
99
131
  const playStationRef = useRef(() => undefined);
100
132
  const screenRef = useRef(screen);
101
133
  const selectedRef = useRef(selected);
134
+ const settingsPageRef = useRef(settingsPage);
135
+ const selectedBySettingsPageRef = useRef({});
102
136
  const selectedByScreenRef = useRef({});
103
137
  const stationContextsRef = useRef(stationContexts);
104
138
  const lastStationContextKeyRef = useRef('explore');
@@ -118,8 +152,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
118
152
  const nearbyRequestRef = useRef(0);
119
153
  const skipBrokenTimerRef = useRef(null);
120
154
  const volumeRequestRef = useRef(0);
155
+ const agentHandlerRef = useRef(async () => {
156
+ throw new Error('RadioCLI is still starting.');
157
+ });
158
+ const showTransientMessage = useCallback((nextMessage) => {
159
+ if (transientMessageTimerRef.current) {
160
+ clearTimeout(transientMessageTimerRef.current);
161
+ }
162
+ setMessage(nextMessage);
163
+ transientMessageTimerRef.current = setTimeout(() => {
164
+ setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
165
+ transientMessageTimerRef.current = null;
166
+ }, TRANSIENT_NOTICE_MS);
167
+ }, []);
121
168
  const theme = library.settings.theme;
122
- const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
169
+ const displayMode = useMemo(() => resolveDisplayMode(library.settings, process.env, { screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.() }), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout]);
123
170
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
124
171
  const diagnostics = player.diagnostics();
125
172
  const filterLabel = formatFilterLabel(filters);
@@ -141,6 +188,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
141
188
  }), [library, libraryStations, stationContexts]);
142
189
  screenRef.current = screen;
143
190
  selectedRef.current = selected;
191
+ settingsPageRef.current = settingsPage;
144
192
  loadingStationsRef.current = loadingStations;
145
193
  stationContextsRef.current = activeStationContexts;
146
194
  exploreCursorRef.current = exploreCursor;
@@ -171,7 +219,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
171
219
  'alarm-ringing': 0,
172
220
  'airplay-settings': 0,
173
221
  'airplay-code': 1,
174
- settings: settingsItems.length,
222
+ settings: settingsItemsForPage(settingsPage).length,
175
223
  help: helpItemCount
176
224
  });
177
225
  itemCountsRef.current = {
@@ -191,7 +239,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
191
239
  'alarm-ringing': 0,
192
240
  'airplay-settings': availableAirPlayDevices.length,
193
241
  'airplay-code': 1,
194
- settings: settingsItems.length,
242
+ settings: settingsItemsForPage(settingsPage).length,
195
243
  help: helpItemCount
196
244
  };
197
245
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
@@ -207,12 +255,31 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
207
255
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
208
256
  const layout = computeTerminalLayout(columns, rows, footerRows);
209
257
  const frameWidth = layout.frameWidth;
210
- const mouseReportingActive = !commandMode &&
258
+ const mouseReportingActive = !displayMode.screenReader &&
259
+ process.env.TERM?.toLowerCase() !== 'dumb' &&
260
+ !commandMode &&
211
261
  !capturingTransportAction &&
212
262
  !editingCountryFilter &&
213
263
  shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
214
264
  useEffect(() => player.onChange(setPlayback), [player]);
215
- useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]);
265
+ useEffect(() => {
266
+ if (!stdin.isTTY)
267
+ return;
268
+ try {
269
+ const unregister = registerTuiPresence();
270
+ return () => {
271
+ try {
272
+ unregister();
273
+ }
274
+ catch {
275
+ console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');
276
+ }
277
+ };
278
+ }
279
+ catch {
280
+ setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.');
281
+ }
282
+ }, [stdin]);
216
283
  const playingStationRef = useRef(null);
217
284
  playingStationRef.current = playingStation;
218
285
  const activeListeningStationRef = useRef(null);
@@ -224,24 +291,24 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
224
291
  const key = stationKey(station);
225
292
  if (activeListeningStationRef.current !== key) {
226
293
  activeListeningStationRef.current = key;
227
- setLibrary(store.startListeningSession(station));
294
+ persistLibrary(() => store.startListeningSession(station));
228
295
  }
229
296
  return;
230
297
  }
231
298
  if (activeListeningStationRef.current) {
232
299
  activeListeningStationRef.current = null;
233
- setLibrary(store.finishActiveListeningSession());
300
+ persistLibrary(() => store.finishActiveListeningSession());
234
301
  }
235
- }, [playback.ready, playback.state, playingStation, store]);
302
+ }, [persistLibrary, playback.ready, playback.state, playingStation, store]);
236
303
  useEffect(() => {
237
304
  if (playback.state !== 'playing' || !playback.ready || !playingStation) {
238
305
  return;
239
306
  }
240
307
  const timer = setInterval(() => {
241
- setLibrary(store.checkpointActiveListeningSession());
308
+ persistLibrary(() => store.checkpointActiveListeningSession());
242
309
  }, LISTENING_HEARTBEAT_MS);
243
310
  return () => clearInterval(timer);
244
- }, [playback.ready, playback.state, playingStation, store]);
311
+ }, [persistLibrary, playback.ready, playback.state, playingStation, store]);
245
312
  useEffect(() => player.onMetadata(metadata => {
246
313
  setNowPlaying(metadata);
247
314
  const station = playingStationRef.current;
@@ -250,9 +317,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
250
317
  if (lastRecordedTrackRef.current === trackKey)
251
318
  return;
252
319
  lastRecordedTrackRef.current = trackKey;
253
- setLibrary(store.recordTrack(station, metadata.title));
320
+ persistLibrary(() => store.recordTrack(station, metadata.title));
254
321
  }
255
- }), [player, store]);
322
+ }), [persistLibrary, player, store]);
256
323
  useEffect(() => {
257
324
  if (!stdout.isTTY || !mouseReportingActive) {
258
325
  return;
@@ -270,7 +337,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
270
337
  }, [renderedStationContextKey, screen, selected]);
271
338
  useEffect(() => {
272
339
  if (footerPlayback.state !== 'loading' ||
273
- library.settings.reduceMotion ||
340
+ displayMode.reduceMotion ||
274
341
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
275
342
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
276
343
  setSpinnerFrame(0);
@@ -278,7 +345,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
278
345
  }
279
346
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
280
347
  return () => clearInterval(timer);
281
- }, [footerPlayback.state, library.settings.reduceMotion]);
348
+ }, [footerPlayback.state, displayMode.reduceMotion]);
282
349
  useEffect(() => {
283
350
  if ((screen === 'countries' || screen === 'map') &&
284
351
  countries.length === 0 &&
@@ -317,27 +384,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
317
384
  const delayMs = sleepUntil - Date.now();
318
385
  if (delayMs <= 0) {
319
386
  tuneRequestRef.current += 1;
320
- setLibrary(store.finishActiveListeningSession());
321
- void player.stop();
387
+ persistLibrary(() => store.finishActiveListeningSession());
388
+ void player.stop().catch(reportActionError);
322
389
  setSleepUntil(null);
323
390
  return;
324
391
  }
325
392
  const timer = setTimeout(() => {
326
393
  tuneRequestRef.current += 1;
327
- setLibrary(store.finishActiveListeningSession());
328
- void player.stop();
394
+ persistLibrary(() => store.finishActiveListeningSession());
395
+ void player.stop().catch(reportActionError);
329
396
  setSleepUntil(null);
330
397
  }, delayMs);
331
398
  return () => clearTimeout(timer);
332
- }, [player, sleepUntil, store]);
399
+ }, [persistLibrary, player, reportActionError, sleepUntil, store]);
333
400
  useEffect(() => {
334
401
  const backends = player.refreshDetectedBackends();
335
402
  setAvailableBackends(backends);
336
- void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
403
+ const network = networkPolicy();
404
+ if (airPlaySupported && !network.offline && !network.lowBandwidth) {
405
+ void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
406
+ }
337
407
  if (backends.length === 0) {
338
408
  setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
339
409
  }
340
- }, [player]);
410
+ }, [airPlaySupported, player]);
341
411
  const refreshProviderHealth = useCallback(() => {
342
412
  providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
343
413
  }, [providers]);
@@ -345,14 +415,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
345
415
  refreshProviderHealth();
346
416
  }, [refreshProviderHealth]);
347
417
  const refreshUpdateCheck = useCallback(async () => {
348
- const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
349
- setLibrary(store.updateCheckState(updateCheck));
418
+ const updateCheck = await updateChecker({ currentVersion: installedVersion });
419
+ persistLibrary(() => store.updateCheckState(updateCheck));
350
420
  return updateCheck;
351
- }, [installedVersion, store]);
421
+ }, [installedVersion, persistLibrary, store, updateChecker]);
352
422
  useEffect(() => {
353
- if (!shouldCheckForUpdate(library.updateCheck)) {
423
+ if (automaticUpdateCheckStartedRef.current ||
424
+ !automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
354
425
  return;
355
426
  }
427
+ automaticUpdateCheckStartedRef.current = true;
356
428
  let cancelled = false;
357
429
  void refreshUpdateCheck().then(updateCheck => {
358
430
  if (cancelled) {
@@ -362,11 +434,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
362
434
  announcedUpdateRef.current = true;
363
435
  setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
364
436
  }
437
+ }).catch(error => {
438
+ if (!cancelled)
439
+ reportActionError(error);
365
440
  });
366
441
  return () => {
367
442
  cancelled = true;
368
443
  };
369
- }, [library.updateCheck, refreshUpdateCheck]);
444
+ }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
370
445
  const setStationContextFor = useCallback((key, context) => {
371
446
  setStationContexts(current => ({ ...current, [key]: context }));
372
447
  }, []);
@@ -376,6 +451,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
376
451
  const nextLibrary = store.updateSettings(settings);
377
452
  settingsRef.current = nextLibrary.settings;
378
453
  setLibrary(nextLibrary);
454
+ setPersistenceWarning(null);
379
455
  return nextLibrary;
380
456
  }, [store]);
381
457
  useEffect(() => {
@@ -387,9 +463,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
387
463
  setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
388
464
  return;
389
465
  }
390
- updateSettings({ preferredBackend });
391
- setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
392
- }, [availableBackends, selectedAirPlayDevice, updateSettings]);
466
+ try {
467
+ updateSettings({ preferredBackend });
468
+ setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
469
+ }
470
+ catch (error) {
471
+ reportActionError(error);
472
+ }
473
+ }, [availableBackends, reportActionError, selectedAirPlayDevice, updateSettings]);
393
474
  const updateMediaKeys = useCallback((mediaKeys) => {
394
475
  updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
395
476
  }, [updateSettings]);
@@ -414,6 +495,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
414
495
  const destination = screenRef.current === 'help' && next === 'home'
415
496
  ? helpReturnScreenRef.current
416
497
  : next;
498
+ if (destination === 'settings' &&
499
+ screenRef.current !== 'settings' &&
500
+ screenRef.current !== 'airplay-settings' &&
501
+ screenRef.current !== 'airplay-code') {
502
+ settingsPageRef.current = 'root';
503
+ setSettingsPage('root');
504
+ itemCountsRef.current.settings = settingsItemsForPage('root').length;
505
+ selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
506
+ }
417
507
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
418
508
  const remembered = selectedByScreenRef.current[destination] ?? 0;
419
509
  const nextSelection = options.resetSelection ? 0 : remembered;
@@ -423,10 +513,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
423
513
  setMessage(null);
424
514
  }
425
515
  }, []);
516
+ const openSettingsPage = useCallback((next) => {
517
+ selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
518
+ settingsPageRef.current = next;
519
+ setSettingsPage(next);
520
+ const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
521
+ selectedRef.current = nextSelection;
522
+ setSelected(nextSelection);
523
+ setMessage(null);
524
+ }, []);
525
+ const closeSettingsPage = useCallback(() => {
526
+ if (settingsPageRef.current === 'root') {
527
+ go('home');
528
+ return;
529
+ }
530
+ openSettingsPage('root');
531
+ }, [go, openSettingsPage]);
426
532
  const openAirPlayCode = useCallback(() => {
533
+ if (!airPlaySupported) {
534
+ showTransientMessage(airPlayMacOSOnlyMessage);
535
+ return;
536
+ }
427
537
  setAirPlayCode('');
428
538
  go('airplay-code', { resetSelection: true, clearMessage: false });
429
- }, [go]);
539
+ }, [airPlaySupported, go, showTransientMessage]);
430
540
  useEffect(() => {
431
541
  if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
432
542
  openAirPlayCode();
@@ -434,13 +544,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
434
544
  }, [openAirPlayCode, playback.backend, playback.message]);
435
545
  const shutdown = useCallback(() => {
436
546
  tuneRequestRef.current += 1;
437
- store.finishActiveListeningSession();
438
- player.stop().finally(exit);
439
- }, [exit, player, store]);
547
+ persistLibrary(() => store.finishActiveListeningSession());
548
+ void player.stop().catch(reportActionError).finally(exit);
549
+ }, [exit, persistLibrary, player, reportActionError, store]);
440
550
  useEffect(() => {
441
551
  const finishForSignal = () => {
442
- store.finishActiveListeningSession();
443
- void player.stop().finally(() => process.exit(0));
552
+ persistLibrary(() => store.finishActiveListeningSession());
553
+ void player.stop().catch(reportActionError).finally(() => process.exit(0));
444
554
  };
445
555
  process.once('SIGTERM', finishForSignal);
446
556
  process.once('SIGHUP', finishForSignal);
@@ -448,7 +558,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
448
558
  process.off('SIGTERM', finishForSignal);
449
559
  process.off('SIGHUP', finishForSignal);
450
560
  };
451
- }, [player, store]);
561
+ }, [persistLibrary, player, reportActionError, store]);
452
562
  const showStationContext = useCallback((context, next = 'stations', options = {}) => {
453
563
  setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
454
564
  go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
@@ -650,7 +760,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
650
760
  });
651
761
  selectedByScreenRef.current.search = 0;
652
762
  lastSubmittedSearchRef.current = query.trim();
653
- setLibrary(store.addSearch(query));
763
+ persistLibrary(() => store.addSearch(query));
654
764
  searchHistoryRef.current = { cursor: -1, draft: '' };
655
765
  setSelected(0);
656
766
  setEditingSearch(true);
@@ -664,7 +774,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
664
774
  if (requestId === searchRequestRef.current)
665
775
  setLoadingStations(false);
666
776
  }
667
- }, [filters, providers, searchQuery, setStationContextFor, store]);
777
+ }, [filters, persistLibrary, providers, searchQuery, setStationContextFor, store]);
668
778
  const loadMoreSearchResults = useCallback(async () => {
669
779
  const context = stationContextsRef.current.search;
670
780
  const query = context.query;
@@ -865,11 +975,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
865
975
  await player.play(station, resolved.url);
866
976
  if (requestId !== tuneRequestRef.current)
867
977
  return;
978
+ playingStationRef.current = station;
868
979
  setPlayingStation(station);
869
980
  setTuningStation(null);
870
981
  playbackQueueRef.current = queue;
871
- const nextLibrary = store.addRecent(station);
872
- if (screenRef.current === 'library') {
982
+ const nextLibrary = persistLibrary(() => store.addRecent(station));
983
+ if (nextLibrary && screenRef.current === 'library') {
873
984
  const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
874
985
  const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station));
875
986
  if (nextLibraryIndex >= 0) {
@@ -877,7 +988,6 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
877
988
  setSelected(nextLibraryIndex);
878
989
  }
879
990
  }
880
- setLibrary(nextLibrary);
881
991
  if (options.openNowPlaying) {
882
992
  go('now-playing');
883
993
  }
@@ -903,7 +1013,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
903
1013
  setTuningStation(null);
904
1014
  setMessage(message);
905
1015
  }
906
- }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
1016
+ }, [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
907
1017
  // Resume the most recent station on launch when opted in, like a radio
908
1018
  // powering back on to its last frequency. Runs once and never auto-navigates.
909
1019
  const didResumeRef = useRef(false);
@@ -929,7 +1039,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
929
1039
  setTuningStation(null);
930
1040
  setMessage('Auto-skip canceled. Choose a station and press Enter.');
931
1041
  }, []);
932
- const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
1042
+ const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
933
1043
  if (transientFooterMessageTimerRef.current) {
934
1044
  clearTimeout(transientFooterMessageTimerRef.current);
935
1045
  }
@@ -955,31 +1065,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
955
1065
  }
956
1066
  const wasFavorite = store.isFavorite(station);
957
1067
  setLibrary(store.toggleFavorite(station));
1068
+ setPersistenceWarning(null);
958
1069
  const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
959
- if (screenRef.current === 'library') {
1070
+ if (screenRef.current === 'library' || screenRef.current === 'search') {
960
1071
  showTransientFooterMessage(favoriteMessage);
961
1072
  }
962
1073
  else {
963
- setMessage(favoriteMessage);
1074
+ showTransientMessage(favoriteMessage);
964
1075
  }
965
1076
  if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
966
1077
  // Best-effort upvote back to the directory; never blocks favoriting.
967
- void providers.vote(station);
1078
+ void providers.vote(station).catch(reportActionError);
968
1079
  }
969
- }, [providers, showTransientFooterMessage, store]);
1080
+ }, [providers, reportActionError, showTransientFooterMessage, showTransientMessage, store]);
970
1081
  const showControlResult = useCallback((result) => {
971
1082
  if (!result.ok && result.message) {
972
1083
  setMessage(result.message);
973
1084
  }
974
1085
  }, []);
975
- const openStationHomepage = useCallback((station) => {
1086
+ const openStationHomepage = useCallback(async (station) => {
976
1087
  if (!station?.homepage) {
977
1088
  setMessage('This station has no homepage.');
978
1089
  return;
979
1090
  }
980
- setMessage(openExternal(station.homepage)
1091
+ const url = safeExternalHttpUrl(station.homepage);
1092
+ if (!url) {
1093
+ setMessage('This station has no valid HTTP(S) homepage.');
1094
+ return;
1095
+ }
1096
+ setMessage(await openExternal(url)
981
1097
  ? `Opening homepage: ${station.name}`
982
- : 'That station homepage is not a valid HTTP(S) URL.');
1098
+ : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}`);
983
1099
  }, []);
984
1100
  const copyStationUrl = useCallback(async (station) => {
985
1101
  if (!station) {
@@ -990,12 +1106,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
990
1106
  if (!url) {
991
1107
  url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
992
1108
  }
993
- if (!url) {
994
- setMessage(`No stream URL available for ${station.name}.`);
1109
+ const safeUrl = url ? safeMediaTarget(url) : null;
1110
+ if (!safeUrl) {
1111
+ setMessage(`No safe stream URL available for ${station.name}.`);
995
1112
  return;
996
1113
  }
997
- const copied = copyToClipboard(url);
998
- setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`);
1114
+ const copied = await copyToClipboard(safeUrl);
1115
+ setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`);
999
1116
  }, [providers]);
1000
1117
  const submitAirPlayCode = useCallback((code) => {
1001
1118
  const result = player.submitAirPlayPasscode(code);
@@ -1016,8 +1133,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1016
1133
  updateSettings({ volume: player.getState().volume });
1017
1134
  }
1018
1135
  showControlResult(result);
1019
- });
1020
- }, [player, showControlResult, updateSettings]);
1136
+ }).catch(reportActionError);
1137
+ }, [player, reportActionError, showControlResult, updateSettings]);
1021
1138
  const adjustVolume = useCallback((delta) => {
1022
1139
  const requestId = volumeRequestRef.current + 1;
1023
1140
  volumeRequestRef.current = requestId;
@@ -1026,24 +1143,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1026
1143
  updateSettings({ volume: player.getState().volume });
1027
1144
  }
1028
1145
  showControlResult(result);
1029
- });
1030
- }, [player, showControlResult, updateSettings]);
1146
+ }).catch(reportActionError);
1147
+ }, [player, reportActionError, showControlResult, updateSettings]);
1031
1148
  const toggleMute = useCallback(() => {
1032
- void player.toggleMute().then(showControlResult);
1033
- }, [player, showControlResult]);
1149
+ void player.toggleMute().then(showControlResult).catch(reportActionError);
1150
+ }, [player, reportActionError, showControlResult]);
1034
1151
  const togglePause = useCallback(() => {
1035
- void player.togglePause().then(showControlResult);
1036
- }, [player, showControlResult]);
1037
- const showTransientMessage = useCallback((nextMessage) => {
1038
- if (transientMessageTimerRef.current) {
1039
- clearTimeout(transientMessageTimerRef.current);
1040
- }
1041
- setMessage(nextMessage);
1042
- transientMessageTimerRef.current = setTimeout(() => {
1043
- setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
1044
- transientMessageTimerRef.current = null;
1045
- }, VISUALIZER_MESSAGE_MS);
1046
- }, []);
1152
+ void player.togglePause().then(showControlResult).catch(reportActionError);
1153
+ }, [player, reportActionError, showControlResult]);
1047
1154
  const cycleDisplayColor = useCallback(() => {
1048
1155
  const theme = nextTheme(settingsRef.current.theme);
1049
1156
  updateSettings({ theme });
@@ -1082,6 +1189,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1082
1189
  setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
1083
1190
  }, [updateSettings]);
1084
1191
  const refreshAirPlayTargets = useCallback(async (announce = true) => {
1192
+ if (!airPlaySupported) {
1193
+ if (announce)
1194
+ showTransientMessage(airPlayMacOSOnlyMessage);
1195
+ return [];
1196
+ }
1197
+ if (networkPolicy().offline) {
1198
+ setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.');
1199
+ return [];
1200
+ }
1085
1201
  try {
1086
1202
  const devices = await player.refreshAirPlayDevices();
1087
1203
  setAvailableAirPlayDevices(devices);
@@ -1104,13 +1220,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1104
1220
  }
1105
1221
  return [];
1106
1222
  }
1107
- }, [player]);
1223
+ }, [airPlaySupported, player, showTransientMessage]);
1108
1224
  const openAirPlaySettings = useCallback(() => {
1225
+ if (!airPlaySupported) {
1226
+ showTransientMessage(airPlayMacOSOnlyMessage);
1227
+ return;
1228
+ }
1109
1229
  const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
1110
1230
  selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
1111
1231
  go('airplay-settings', { resetSelection: false });
1112
1232
  void refreshAirPlayTargets(false);
1113
- }, [availableAirPlayDevices, go, refreshAirPlayTargets]);
1233
+ }, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]);
1114
1234
  const selectAirPlayDeviceAt = useCallback((index) => {
1115
1235
  const device = availableAirPlayDevices[index];
1116
1236
  if (!device) {
@@ -1184,6 +1304,59 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1184
1304
  updateSettings({ [key]: next });
1185
1305
  setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
1186
1306
  }, [updateSettings]);
1307
+ const toggleAgentSetting = useCallback((key) => {
1308
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1309
+ const next = !agentControl[key];
1310
+ updateSettings({ agentControl: { ...agentControl, [key]: next } });
1311
+ setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${next ? 'on' : 'off'}.`);
1312
+ }, [updateSettings]);
1313
+ const setAgentIntegrationEnabled = useCallback(async () => {
1314
+ if (configuringMcpRef.current) {
1315
+ setMessage('Agent integration setup is already in progress.');
1316
+ return;
1317
+ }
1318
+ configuringMcpRef.current = true;
1319
+ const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
1320
+ setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
1321
+ try {
1322
+ const results = await mcpConfigurator(enabled, mcpRuntime(), null);
1323
+ const failed = results.filter(item => item.status === 'failed');
1324
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1325
+ updateSettings({ agentControl: { ...agentControl, enabled } });
1326
+ setMessage(integrationResultMessage(enabled, results, failed));
1327
+ }
1328
+ catch (error) {
1329
+ if (!enabled) {
1330
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1331
+ updateSettings({ agentControl: { ...agentControl, enabled: false } });
1332
+ }
1333
+ setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
1334
+ }
1335
+ finally {
1336
+ configuringMcpRef.current = false;
1337
+ }
1338
+ }, [mcpConfigurator, updateSettings]);
1339
+ const repairMcpIntegrations = useCallback(async () => {
1340
+ if (configuringMcpRef.current) {
1341
+ setMessage('Agent integration setup is already in progress.');
1342
+ return;
1343
+ }
1344
+ configuringMcpRef.current = true;
1345
+ setMessage('Checking and repairing agent integrations...');
1346
+ try {
1347
+ const results = await mcpConfigurator(true, mcpRuntime(), null);
1348
+ const failed = results.filter(item => item.status === 'failed');
1349
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1350
+ updateSettings({ agentControl: { ...agentControl, enabled: true } });
1351
+ setMessage(integrationResultMessage(true, results, failed));
1352
+ }
1353
+ catch (error) {
1354
+ setMessage(`MCP repair failed: ${error instanceof Error ? error.message : String(error)}`);
1355
+ }
1356
+ finally {
1357
+ configuringMcpRef.current = false;
1358
+ }
1359
+ }, [mcpConfigurator, updateSettings]);
1187
1360
  const cycleSleepTimer = useCallback(() => {
1188
1361
  const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
1189
1362
  const next = nextSleepTimerMinutes(currentMinutes);
@@ -1261,7 +1434,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1261
1434
  }
1262
1435
  }
1263
1436
  const command = updateCommandForInstall();
1264
- const copied = copyToClipboard(command.command);
1437
+ const copied = await copyToClipboard(command.command);
1265
1438
  const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
1266
1439
  const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
1267
1440
  const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
@@ -1273,7 +1446,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1273
1446
  return;
1274
1447
  }
1275
1448
  const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
1276
- if (!currentUpdateCheck?.updateAvailable) {
1449
+ if (!updateAvailableForVersion(currentUpdateCheck, installedVersion)) {
1277
1450
  setMessage('Checking for updates...');
1278
1451
  const latest = await refreshUpdateCheck();
1279
1452
  if (latest.error) {
@@ -1293,12 +1466,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1293
1466
  const result = await installUpdate(command.command);
1294
1467
  installingUpdateRef.current = false;
1295
1468
  if (result.ok) {
1296
- setMessage(`Update installed. Restart RadioCLI to use the new version.`);
1469
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1470
+ if (agentControl.enabled) {
1471
+ try {
1472
+ const repaired = await mcpConfigurator(true, mcpRuntime(), null);
1473
+ const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
1474
+ setMessage(`Update installed.${suffix} Restart RadioCLI and open agent clients.`);
1475
+ }
1476
+ catch {
1477
+ setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.');
1478
+ }
1479
+ }
1480
+ else {
1481
+ setMessage('Update installed. Restart RadioCLI to use the new version.');
1482
+ }
1297
1483
  return;
1298
1484
  }
1299
1485
  const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
1300
1486
  setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
1301
- }, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
1487
+ }, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
1302
1488
  const executeCommand = useCommandExecutor({
1303
1489
  beginLearningTransportKey,
1304
1490
  countries,
@@ -1306,6 +1492,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1306
1492
  loadCountry,
1307
1493
  openAirPlaySettings,
1308
1494
  openLibrary,
1495
+ persistLibrary,
1309
1496
  player,
1310
1497
  playingStation,
1311
1498
  providers,
@@ -1350,6 +1537,167 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1350
1537
  void playStation(nextStation, { queue });
1351
1538
  }
1352
1539
  }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
1540
+ agentHandlerRef.current = async (command) => {
1541
+ const sessionStatus = () => ({
1542
+ owner: 'tui',
1543
+ playback: player.getState(),
1544
+ station: playingStationRef.current,
1545
+ queue: playbackQueueRef.current?.stations ?? [],
1546
+ output: {
1547
+ preferredBackend: settingsRef.current.preferredBackend,
1548
+ preferredAirPlayDevice: settingsRef.current.preferredAirPlayDevice
1549
+ }
1550
+ });
1551
+ const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
1552
+ if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) {
1553
+ return respond(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined);
1554
+ }
1555
+ if (command.type === 'status')
1556
+ return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
1557
+ if (command.type === 'play') {
1558
+ if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) {
1559
+ return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
1560
+ }
1561
+ const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
1562
+ await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
1563
+ const state = player.getState();
1564
+ const playingRequestedStation = playingStationRef.current
1565
+ && stationKey(playingStationRef.current) === stationKey(command.station)
1566
+ && state.state === 'playing'
1567
+ && state.ready;
1568
+ return playingRequestedStation
1569
+ ? respond(`Playing ${command.station.name}.`)
1570
+ : respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
1571
+ }
1572
+ if (command.type === 'pause') {
1573
+ const control = await player.pause();
1574
+ return respond(control.message ?? 'Paused.', control.ok);
1575
+ }
1576
+ if (command.type === 'resume') {
1577
+ const control = await player.resume();
1578
+ return respond(control.message ?? 'Resumed.', control.ok);
1579
+ }
1580
+ if (command.type === 'stop') {
1581
+ tuneRequestRef.current += 1;
1582
+ persistLibrary(() => store.finishActiveListeningSession());
1583
+ await player.stop();
1584
+ playingStationRef.current = null;
1585
+ setPlayingStation(null);
1586
+ setTuningStation(null);
1587
+ playbackQueueRef.current = null;
1588
+ return respond('Stopped RadioCLI.');
1589
+ }
1590
+ if (command.type === 'alarm-preempt') {
1591
+ tuneRequestRef.current += 1;
1592
+ persistLibrary(() => store.finishActiveListeningSession());
1593
+ await player.stop();
1594
+ playingStationRef.current = null;
1595
+ setPlayingStation(null);
1596
+ setTuningStation(null);
1597
+ playbackQueueRef.current = null;
1598
+ return respond('Interactive playback yielded to the alarm.');
1599
+ }
1600
+ if (command.type === 'set-volume') {
1601
+ const control = await player.setVolume(clampVolume(command.volume));
1602
+ if (control.ok)
1603
+ updateSettings({ volume: player.getState().volume });
1604
+ return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
1605
+ }
1606
+ if (command.type === 'set-muted') {
1607
+ const control = await player.setMuted(command.muted);
1608
+ return respond(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
1609
+ }
1610
+ if (command.type === 'set-favorite') {
1611
+ const station = command.station ?? playingStationRef.current;
1612
+ if (!station)
1613
+ return respond('No active station to favorite.', false);
1614
+ const current = store.isFavorite(station);
1615
+ if (current !== command.favorite)
1616
+ toggleFavorite(station);
1617
+ return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
1618
+ }
1619
+ if (command.type === 'airplay-list') {
1620
+ if (networkPolicy().offline)
1621
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
1622
+ const devices = await refreshAirPlayTargets(false);
1623
+ return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
1624
+ }
1625
+ if (command.type === 'airplay-select') {
1626
+ if (networkPolicy().offline)
1627
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
1628
+ const devices = await refreshAirPlayTargets(false);
1629
+ const device = devices.find(item => item.id === command.deviceId);
1630
+ if (!device)
1631
+ return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
1632
+ if (device.local) {
1633
+ const backend = preferredLocalPlaybackBackend(availableBackends);
1634
+ if (!backend)
1635
+ return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1636
+ updateSettings({ preferredBackend: backend });
1637
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1638
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1639
+ }
1640
+ return respond(`Audio output set to this device (${backend}).`);
1641
+ }
1642
+ if (!isAirPlayBackendAvailable(availableBackends))
1643
+ return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
1644
+ updateSettings({ preferredAirPlayDevice: device.id });
1645
+ updateSettings({ preferredBackend: 'airplay' });
1646
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1647
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1648
+ }
1649
+ return respond(`Audio output set to AirPlay receiver ${device.name}.`);
1650
+ }
1651
+ if (command.type === 'airplay-local') {
1652
+ const backend = preferredLocalPlaybackBackend(availableBackends);
1653
+ if (!backend)
1654
+ return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1655
+ updateSettings({ preferredBackend: backend });
1656
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1657
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1658
+ }
1659
+ return respond(`Audio output set to this device (${backend}).`);
1660
+ }
1661
+ if (command.type === 'airplay-passcode') {
1662
+ const control = player.submitAirPlayPasscode(command.code);
1663
+ return respond(control.message ?? 'AirPlay code sent.', control.ok);
1664
+ }
1665
+ if (command.type === 'update-settings') {
1666
+ updateSettings(command.settings);
1667
+ return respond('RadioCLI settings updated.');
1668
+ }
1669
+ const active = playingStationRef.current;
1670
+ const queue = playbackQueueRef.current;
1671
+ if (!active || !queue?.stations.length)
1672
+ return respond('No playback queue is available.', false);
1673
+ const currentIndex = queue.stations.findIndex(item => stationKey(item) === stationKey(active));
1674
+ const delta = command.type === 'next' ? 1 : -1;
1675
+ const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
1676
+ if (!next)
1677
+ return respond('No adjacent station is available.', false);
1678
+ await playStation(next, { queue });
1679
+ return respond(`Playing ${next.name}.`);
1680
+ };
1681
+ useEffect(() => {
1682
+ if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
1683
+ return;
1684
+ let disposed = false;
1685
+ let close;
1686
+ void startRadioSession(command => agentHandlerRef.current(command)).then(async (session) => {
1687
+ if (disposed) {
1688
+ await session.close();
1689
+ return;
1690
+ }
1691
+ close = session.close;
1692
+ if (initialAgentCommand)
1693
+ await agentHandlerRef.current(initialAgentCommand);
1694
+ }).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
1695
+ return () => {
1696
+ disposed = true;
1697
+ if (close)
1698
+ void close();
1699
+ };
1700
+ }, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]);
1353
1701
  useAppInput({
1354
1702
  adjustVolume,
1355
1703
  airPlayCode,
@@ -1378,6 +1726,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1378
1726
  openAdjacentTab,
1379
1727
  openAirPlayCode,
1380
1728
  openAirPlaySettings,
1729
+ openSettingsPage,
1730
+ closeSettingsPage,
1381
1731
  openScreen,
1382
1732
  handleAlarmInput: alarmTui.handleInput,
1383
1733
  openAlarmForStation: alarmTui.openForStation,
@@ -1394,9 +1744,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1394
1744
  void refreshAirPlayTargets();
1395
1745
  },
1396
1746
  resetLearnedTransportKeys,
1747
+ repairMcpIntegrations,
1748
+ setAgentIntegrationEnabled,
1397
1749
  runSearch,
1398
1750
  saveLearnedTransportKey,
1399
1751
  screen,
1752
+ settingsPage,
1400
1753
  searchQuery,
1401
1754
  selectedRef,
1402
1755
  selectedStationForInput,
@@ -1420,6 +1773,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1420
1773
  toggleMute,
1421
1774
  togglePause,
1422
1775
  toggleSetting,
1776
+ toggleAgentSetting,
1423
1777
  toggleNearbyLocation,
1424
1778
  toggleDirectoryVoting,
1425
1779
  toggleRadioGarden,
@@ -1442,6 +1796,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1442
1796
  spinnerFrame
1443
1797
  });
1444
1798
  const basePageFooter = pageFooterText({
1799
+ airPlaySupported,
1445
1800
  canEnterAirPlayCode,
1446
1801
  capturingTransportAction,
1447
1802
  commandMode,
@@ -1449,7 +1804,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1449
1804
  editingCountryFilter,
1450
1805
  editingSearch,
1451
1806
  playbackBackend: playback.backend,
1452
- screen
1807
+ screen,
1808
+ settingsPage
1453
1809
  });
1454
1810
  const pageFooter = alarmTui.activeAlarms.length > 0 && screen !== 'alarm-ringing'
1455
1811
  ? `ALARM PLAYING · ! controls · ${basePageFooter}`
@@ -1460,7 +1816,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1460
1816
  (screen === 'search' && editingSearch) ||
1461
1817
  screen === 'airplay-code');
1462
1818
  const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
1463
- const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
1819
+ const statusMessage = message ?? persistenceWarning ?? presenceWarning;
1820
+ const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
1464
1821
  ? pageFooter
1465
1822
  : footerPlayback.state === 'loading' && playbackFooter
1466
1823
  ? playbackFooter
@@ -1468,19 +1825,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1468
1825
  ? microPlaybackControlsText(playback.backend)
1469
1826
  : pageFooter);
1470
1827
  const compactGlobalFooter = '←/→ tabs · ? help · q quit';
1471
- const versionLabel = `v${installedVersion}`;
1472
- const versionReserve = displayWidth(versionLabel) + 2;
1473
- const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1828
+ const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
1829
+ const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
1474
1830
  const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
1475
- const compactStatus = footerMessage ?? message ?? playbackFooter;
1831
+ const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
1476
1832
  const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
1477
1833
  const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
1478
1834
  const microLegendWidth = Math.max(1, frameWidth - versionReserve);
1479
- const microLegend = commandMode || capturingTransportAction || footerMessage || message
1835
+ const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
1480
1836
  ? microFooter
1481
1837
  : microShortcutFooterText(microFooter, microLegendWidth);
1482
1838
  const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
1483
- return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: Boolean(library.settings.reduceMotion), children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: textDim, children: versionLabel })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: textDim, children: versionLabel })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: textDim, children: versionLabel })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
1839
+ return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: displayMode.reduceMotion, children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlaySupported: airPlaySupported, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, settingsPage: settingsPage, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
1840
+ }
1841
+ function mcpRuntime() {
1842
+ return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
1843
+ }
1844
+ function integrationResultMessage(enabled, results, failed) {
1845
+ if (failed.length > 0) {
1846
+ return `${enabled ? 'Agent control enabled' : 'Agent control disabled'}, but ${failed.map(item => item.client).join(', ')} need attention. Run radiocli mcp status for details.`;
1847
+ }
1848
+ const configured = results.filter(item => item.status === (enabled ? 'configured' : 'removed')).map(item => item.client);
1849
+ if (!enabled)
1850
+ return 'Agent and voice control disabled; detected MCP registrations were removed.';
1851
+ return `Agent and voice control ready${configured.length ? ` for ${configured.join(', ')}` : ''}. Restart open Codex or agent clients, then ask them to control RadioCLI.`;
1484
1852
  }
1485
1853
  function buildLibraryStations(library) {
1486
1854
  const stations = [];
@@ -1496,12 +1864,12 @@ function buildLibraryStations(library) {
1496
1864
  for (const station of library.favorites) {
1497
1865
  addStation(station);
1498
1866
  }
1499
- for (const item of library.recent) {
1500
- addStation(item.station);
1501
- }
1502
1867
  for (const station of library.imported) {
1503
1868
  addStation(station);
1504
1869
  }
1870
+ for (const item of library.recent) {
1871
+ addStation(item.station);
1872
+ }
1505
1873
  return stations;
1506
1874
  }
1507
1875
  function exploreCursorLocation(cursor) {
@@ -1599,7 +1967,7 @@ function audioOutputSwitchLabel(output, backends) {
1599
1967
  return audioOutputLabel(output);
1600
1968
  }
1601
1969
  function librarySubtitle(library) {
1602
- return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
1970
+ return `${library.favorites.length} favorites · ${library.imported.length} imported · ${library.recent.length} recent · new imports before recents`;
1603
1971
  }
1604
1972
  function mouseVisibleRows(screen, layout) {
1605
1973
  if (screen === 'help')