@ciphore/radiocli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
package/dist/ui/App.js CHANGED
@@ -1,10 +1,10 @@
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
3
  import { fileURLToPath } from 'node:url';
4
- import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
+ import { Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize } from 'ink';
5
5
  import { ProviderManager } from '../providers/provider-manager.js';
6
6
  import { PlayerController } from '../player/player-controller.js';
7
- import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
+ import { airPlayMacOSOnlyMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
8
8
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
9
9
  import { defaultAgentControlSettings } from '../types.js';
10
10
  import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
@@ -21,8 +21,10 @@ import { useAppInput } from './use-app-input.js';
21
21
  import { useCommandExecutor } from './use-command-executor.js';
22
22
  import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
23
23
  import { isAirPlayBackendAvailable } from './airplay-settings.js';
24
+ import { networkPolicy } from '../platform/network.js';
24
25
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
25
26
  import { copyToClipboard, openExternal } from './system-actions.js';
27
+ import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
26
28
  import { appVersion } from '../version.js';
27
29
  import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
28
30
  import { helpItemCount } from './help-content.js';
@@ -37,7 +39,7 @@ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, cla
37
39
  import { ReceiverAnimationProvider } from './receiver-animation.js';
38
40
  import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
39
41
  const LOADING_SPINNER_MS = 120;
40
- const VISUALIZER_MESSAGE_MS = 4500;
42
+ const TRANSIENT_NOTICE_MS = 4500;
41
43
  const LISTENING_HEARTBEAT_MS = 30_000;
42
44
  const COUNTRY_STATIONS_PAGE_SIZE = 120;
43
45
  const COUNTRY_STATIONS_LOAD_AHEAD = 12;
@@ -52,15 +54,17 @@ const settingToggleLabel = {
52
54
  mouseSupport: 'Mouse and trackpad scrolling',
53
55
  automaticUpdateChecks: 'Automatic update checks'
54
56
  };
55
- export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
57
+ export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform }) {
56
58
  const { exit } = useApp();
57
59
  const { stdin } = useStdin();
58
60
  const { stdout } = useStdout();
59
61
  const { columns, rows } = useWindowSize();
62
+ const screenReader = useIsScreenReaderEnabled();
60
63
  const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
61
64
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
62
65
  const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
63
66
  const installedVersion = useMemo(() => appVersion(), []);
67
+ const airPlaySupported = isAirPlayPlatformSupported(platform);
64
68
  const [library, setLibrary] = useState(() => store.snapshot());
65
69
  const settingsRef = useRef(library.settings);
66
70
  settingsRef.current = library.settings;
@@ -72,6 +76,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
72
76
  const [selected, setSelected] = useState(0);
73
77
  const [settingsPage, setSettingsPage] = useState('root');
74
78
  const [message, setMessage] = useState(null);
79
+ const [persistenceWarning, setPersistenceWarning] = useState(null);
80
+ const [presenceWarning, setPresenceWarning] = useState(null);
75
81
  const [footerMessage, setFooterMessage] = useState(null);
76
82
  const [countries, setCountries] = useState([]);
77
83
  const [countryFilter, setCountryFilter] = useState('');
@@ -95,6 +101,23 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
95
101
  const [sleepUntil, setSleepUntil] = useState(null);
96
102
  const [showDiagnostics, setShowDiagnostics] = useState(false);
97
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
+ }, []);
98
121
  const announcedUpdateRef = useRef(false);
99
122
  const automaticUpdateCheckStartedRef = useRef(false);
100
123
  const installingUpdateRef = useRef(false);
@@ -132,8 +155,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
132
155
  const agentHandlerRef = useRef(async () => {
133
156
  throw new Error('RadioCLI is still starting.');
134
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
+ }, []);
135
168
  const theme = library.settings.theme;
136
- 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]);
137
170
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
138
171
  const diagnostics = player.diagnostics();
139
172
  const filterLabel = formatFilterLabel(filters);
@@ -222,12 +255,31 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
222
255
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
223
256
  const layout = computeTerminalLayout(columns, rows, footerRows);
224
257
  const frameWidth = layout.frameWidth;
225
- const mouseReportingActive = !commandMode &&
258
+ const mouseReportingActive = !displayMode.screenReader &&
259
+ process.env.TERM?.toLowerCase() !== 'dumb' &&
260
+ !commandMode &&
226
261
  !capturingTransportAction &&
227
262
  !editingCountryFilter &&
228
263
  shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
229
264
  useEffect(() => player.onChange(setPlayback), [player]);
230
- 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]);
231
283
  const playingStationRef = useRef(null);
232
284
  playingStationRef.current = playingStation;
233
285
  const activeListeningStationRef = useRef(null);
@@ -239,24 +291,24 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
239
291
  const key = stationKey(station);
240
292
  if (activeListeningStationRef.current !== key) {
241
293
  activeListeningStationRef.current = key;
242
- setLibrary(store.startListeningSession(station));
294
+ persistLibrary(() => store.startListeningSession(station));
243
295
  }
244
296
  return;
245
297
  }
246
298
  if (activeListeningStationRef.current) {
247
299
  activeListeningStationRef.current = null;
248
- setLibrary(store.finishActiveListeningSession());
300
+ persistLibrary(() => store.finishActiveListeningSession());
249
301
  }
250
- }, [playback.ready, playback.state, playingStation, store]);
302
+ }, [persistLibrary, playback.ready, playback.state, playingStation, store]);
251
303
  useEffect(() => {
252
304
  if (playback.state !== 'playing' || !playback.ready || !playingStation) {
253
305
  return;
254
306
  }
255
307
  const timer = setInterval(() => {
256
- setLibrary(store.checkpointActiveListeningSession());
308
+ persistLibrary(() => store.checkpointActiveListeningSession());
257
309
  }, LISTENING_HEARTBEAT_MS);
258
310
  return () => clearInterval(timer);
259
- }, [playback.ready, playback.state, playingStation, store]);
311
+ }, [persistLibrary, playback.ready, playback.state, playingStation, store]);
260
312
  useEffect(() => player.onMetadata(metadata => {
261
313
  setNowPlaying(metadata);
262
314
  const station = playingStationRef.current;
@@ -265,9 +317,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
265
317
  if (lastRecordedTrackRef.current === trackKey)
266
318
  return;
267
319
  lastRecordedTrackRef.current = trackKey;
268
- setLibrary(store.recordTrack(station, metadata.title));
320
+ persistLibrary(() => store.recordTrack(station, metadata.title));
269
321
  }
270
- }), [player, store]);
322
+ }), [persistLibrary, player, store]);
271
323
  useEffect(() => {
272
324
  if (!stdout.isTTY || !mouseReportingActive) {
273
325
  return;
@@ -285,7 +337,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
285
337
  }, [renderedStationContextKey, screen, selected]);
286
338
  useEffect(() => {
287
339
  if (footerPlayback.state !== 'loading' ||
288
- library.settings.reduceMotion ||
340
+ displayMode.reduceMotion ||
289
341
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
290
342
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
291
343
  setSpinnerFrame(0);
@@ -293,7 +345,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
293
345
  }
294
346
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
295
347
  return () => clearInterval(timer);
296
- }, [footerPlayback.state, library.settings.reduceMotion]);
348
+ }, [footerPlayback.state, displayMode.reduceMotion]);
297
349
  useEffect(() => {
298
350
  if ((screen === 'countries' || screen === 'map') &&
299
351
  countries.length === 0 &&
@@ -332,27 +384,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
332
384
  const delayMs = sleepUntil - Date.now();
333
385
  if (delayMs <= 0) {
334
386
  tuneRequestRef.current += 1;
335
- setLibrary(store.finishActiveListeningSession());
336
- void player.stop();
387
+ persistLibrary(() => store.finishActiveListeningSession());
388
+ void player.stop().catch(reportActionError);
337
389
  setSleepUntil(null);
338
390
  return;
339
391
  }
340
392
  const timer = setTimeout(() => {
341
393
  tuneRequestRef.current += 1;
342
- setLibrary(store.finishActiveListeningSession());
343
- void player.stop();
394
+ persistLibrary(() => store.finishActiveListeningSession());
395
+ void player.stop().catch(reportActionError);
344
396
  setSleepUntil(null);
345
397
  }, delayMs);
346
398
  return () => clearTimeout(timer);
347
- }, [player, sleepUntil, store]);
399
+ }, [persistLibrary, player, reportActionError, sleepUntil, store]);
348
400
  useEffect(() => {
349
401
  const backends = player.refreshDetectedBackends();
350
402
  setAvailableBackends(backends);
351
- 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
+ }
352
407
  if (backends.length === 0) {
353
408
  setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
354
409
  }
355
- }, [player]);
410
+ }, [airPlaySupported, player]);
356
411
  const refreshProviderHealth = useCallback(() => {
357
412
  providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
358
413
  }, [providers]);
@@ -361,9 +416,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
361
416
  }, [refreshProviderHealth]);
362
417
  const refreshUpdateCheck = useCallback(async () => {
363
418
  const updateCheck = await updateChecker({ currentVersion: installedVersion });
364
- setLibrary(store.updateCheckState(updateCheck));
419
+ persistLibrary(() => store.updateCheckState(updateCheck));
365
420
  return updateCheck;
366
- }, [installedVersion, store, updateChecker]);
421
+ }, [installedVersion, persistLibrary, store, updateChecker]);
367
422
  useEffect(() => {
368
423
  if (automaticUpdateCheckStartedRef.current ||
369
424
  !automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
@@ -379,11 +434,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
379
434
  announcedUpdateRef.current = true;
380
435
  setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
381
436
  }
437
+ }).catch(error => {
438
+ if (!cancelled)
439
+ reportActionError(error);
382
440
  });
383
441
  return () => {
384
442
  cancelled = true;
385
443
  };
386
- }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
444
+ }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
387
445
  const setStationContextFor = useCallback((key, context) => {
388
446
  setStationContexts(current => ({ ...current, [key]: context }));
389
447
  }, []);
@@ -393,6 +451,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
393
451
  const nextLibrary = store.updateSettings(settings);
394
452
  settingsRef.current = nextLibrary.settings;
395
453
  setLibrary(nextLibrary);
454
+ setPersistenceWarning(null);
396
455
  return nextLibrary;
397
456
  }, [store]);
398
457
  useEffect(() => {
@@ -404,9 +463,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
404
463
  setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
405
464
  return;
406
465
  }
407
- updateSettings({ preferredBackend });
408
- setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
409
- }, [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]);
410
474
  const updateMediaKeys = useCallback((mediaKeys) => {
411
475
  updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
412
476
  }, [updateSettings]);
@@ -466,9 +530,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
466
530
  openSettingsPage('root');
467
531
  }, [go, openSettingsPage]);
468
532
  const openAirPlayCode = useCallback(() => {
533
+ if (!airPlaySupported) {
534
+ showTransientMessage(airPlayMacOSOnlyMessage);
535
+ return;
536
+ }
469
537
  setAirPlayCode('');
470
538
  go('airplay-code', { resetSelection: true, clearMessage: false });
471
- }, [go]);
539
+ }, [airPlaySupported, go, showTransientMessage]);
472
540
  useEffect(() => {
473
541
  if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
474
542
  openAirPlayCode();
@@ -476,13 +544,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
476
544
  }, [openAirPlayCode, playback.backend, playback.message]);
477
545
  const shutdown = useCallback(() => {
478
546
  tuneRequestRef.current += 1;
479
- store.finishActiveListeningSession();
480
- player.stop().finally(exit);
481
- }, [exit, player, store]);
547
+ persistLibrary(() => store.finishActiveListeningSession());
548
+ void player.stop().catch(reportActionError).finally(exit);
549
+ }, [exit, persistLibrary, player, reportActionError, store]);
482
550
  useEffect(() => {
483
551
  const finishForSignal = () => {
484
- store.finishActiveListeningSession();
485
- void player.stop().finally(() => process.exit(0));
552
+ persistLibrary(() => store.finishActiveListeningSession());
553
+ void player.stop().catch(reportActionError).finally(() => process.exit(0));
486
554
  };
487
555
  process.once('SIGTERM', finishForSignal);
488
556
  process.once('SIGHUP', finishForSignal);
@@ -490,7 +558,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
490
558
  process.off('SIGTERM', finishForSignal);
491
559
  process.off('SIGHUP', finishForSignal);
492
560
  };
493
- }, [player, store]);
561
+ }, [persistLibrary, player, reportActionError, store]);
494
562
  const showStationContext = useCallback((context, next = 'stations', options = {}) => {
495
563
  setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
496
564
  go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
@@ -692,7 +760,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
692
760
  });
693
761
  selectedByScreenRef.current.search = 0;
694
762
  lastSubmittedSearchRef.current = query.trim();
695
- setLibrary(store.addSearch(query));
763
+ persistLibrary(() => store.addSearch(query));
696
764
  searchHistoryRef.current = { cursor: -1, draft: '' };
697
765
  setSelected(0);
698
766
  setEditingSearch(true);
@@ -706,7 +774,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
706
774
  if (requestId === searchRequestRef.current)
707
775
  setLoadingStations(false);
708
776
  }
709
- }, [filters, providers, searchQuery, setStationContextFor, store]);
777
+ }, [filters, persistLibrary, providers, searchQuery, setStationContextFor, store]);
710
778
  const loadMoreSearchResults = useCallback(async () => {
711
779
  const context = stationContextsRef.current.search;
712
780
  const query = context.query;
@@ -911,8 +979,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
911
979
  setPlayingStation(station);
912
980
  setTuningStation(null);
913
981
  playbackQueueRef.current = queue;
914
- const nextLibrary = store.addRecent(station);
915
- if (screenRef.current === 'library') {
982
+ const nextLibrary = persistLibrary(() => store.addRecent(station));
983
+ if (nextLibrary && screenRef.current === 'library') {
916
984
  const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
917
985
  const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station));
918
986
  if (nextLibraryIndex >= 0) {
@@ -920,7 +988,6 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
920
988
  setSelected(nextLibraryIndex);
921
989
  }
922
990
  }
923
- setLibrary(nextLibrary);
924
991
  if (options.openNowPlaying) {
925
992
  go('now-playing');
926
993
  }
@@ -946,7 +1013,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
946
1013
  setTuningStation(null);
947
1014
  setMessage(message);
948
1015
  }
949
- }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
1016
+ }, [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
950
1017
  // Resume the most recent station on launch when opted in, like a radio
951
1018
  // powering back on to its last frequency. Runs once and never auto-navigates.
952
1019
  const didResumeRef = useRef(false);
@@ -972,7 +1039,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
972
1039
  setTuningStation(null);
973
1040
  setMessage('Auto-skip canceled. Choose a station and press Enter.');
974
1041
  }, []);
975
- const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
1042
+ const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
976
1043
  if (transientFooterMessageTimerRef.current) {
977
1044
  clearTimeout(transientFooterMessageTimerRef.current);
978
1045
  }
@@ -998,31 +1065,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
998
1065
  }
999
1066
  const wasFavorite = store.isFavorite(station);
1000
1067
  setLibrary(store.toggleFavorite(station));
1068
+ setPersistenceWarning(null);
1001
1069
  const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
1002
- if (screenRef.current === 'library') {
1070
+ if (screenRef.current === 'library' || screenRef.current === 'search') {
1003
1071
  showTransientFooterMessage(favoriteMessage);
1004
1072
  }
1005
1073
  else {
1006
- setMessage(favoriteMessage);
1074
+ showTransientMessage(favoriteMessage);
1007
1075
  }
1008
1076
  if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
1009
1077
  // Best-effort upvote back to the directory; never blocks favoriting.
1010
- void providers.vote(station);
1078
+ void providers.vote(station).catch(reportActionError);
1011
1079
  }
1012
- }, [providers, showTransientFooterMessage, store]);
1080
+ }, [providers, reportActionError, showTransientFooterMessage, showTransientMessage, store]);
1013
1081
  const showControlResult = useCallback((result) => {
1014
1082
  if (!result.ok && result.message) {
1015
1083
  setMessage(result.message);
1016
1084
  }
1017
1085
  }, []);
1018
- const openStationHomepage = useCallback((station) => {
1086
+ const openStationHomepage = useCallback(async (station) => {
1019
1087
  if (!station?.homepage) {
1020
1088
  setMessage('This station has no homepage.');
1021
1089
  return;
1022
1090
  }
1023
- 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)
1024
1097
  ? `Opening homepage: ${station.name}`
1025
- : 'That station homepage is not a valid HTTP(S) URL.');
1098
+ : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}`);
1026
1099
  }, []);
1027
1100
  const copyStationUrl = useCallback(async (station) => {
1028
1101
  if (!station) {
@@ -1033,12 +1106,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1033
1106
  if (!url) {
1034
1107
  url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
1035
1108
  }
1036
- if (!url) {
1037
- 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}.`);
1038
1112
  return;
1039
1113
  }
1040
- const copied = copyToClipboard(url);
1041
- 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) ?? ''}`);
1042
1116
  }, [providers]);
1043
1117
  const submitAirPlayCode = useCallback((code) => {
1044
1118
  const result = player.submitAirPlayPasscode(code);
@@ -1059,8 +1133,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1059
1133
  updateSettings({ volume: player.getState().volume });
1060
1134
  }
1061
1135
  showControlResult(result);
1062
- });
1063
- }, [player, showControlResult, updateSettings]);
1136
+ }).catch(reportActionError);
1137
+ }, [player, reportActionError, showControlResult, updateSettings]);
1064
1138
  const adjustVolume = useCallback((delta) => {
1065
1139
  const requestId = volumeRequestRef.current + 1;
1066
1140
  volumeRequestRef.current = requestId;
@@ -1069,24 +1143,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1069
1143
  updateSettings({ volume: player.getState().volume });
1070
1144
  }
1071
1145
  showControlResult(result);
1072
- });
1073
- }, [player, showControlResult, updateSettings]);
1146
+ }).catch(reportActionError);
1147
+ }, [player, reportActionError, showControlResult, updateSettings]);
1074
1148
  const toggleMute = useCallback(() => {
1075
- void player.toggleMute().then(showControlResult);
1076
- }, [player, showControlResult]);
1149
+ void player.toggleMute().then(showControlResult).catch(reportActionError);
1150
+ }, [player, reportActionError, showControlResult]);
1077
1151
  const togglePause = useCallback(() => {
1078
- void player.togglePause().then(showControlResult);
1079
- }, [player, showControlResult]);
1080
- const showTransientMessage = useCallback((nextMessage) => {
1081
- if (transientMessageTimerRef.current) {
1082
- clearTimeout(transientMessageTimerRef.current);
1083
- }
1084
- setMessage(nextMessage);
1085
- transientMessageTimerRef.current = setTimeout(() => {
1086
- setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
1087
- transientMessageTimerRef.current = null;
1088
- }, VISUALIZER_MESSAGE_MS);
1089
- }, []);
1152
+ void player.togglePause().then(showControlResult).catch(reportActionError);
1153
+ }, [player, reportActionError, showControlResult]);
1090
1154
  const cycleDisplayColor = useCallback(() => {
1091
1155
  const theme = nextTheme(settingsRef.current.theme);
1092
1156
  updateSettings({ theme });
@@ -1125,6 +1189,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1125
1189
  setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
1126
1190
  }, [updateSettings]);
1127
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
+ }
1128
1201
  try {
1129
1202
  const devices = await player.refreshAirPlayDevices();
1130
1203
  setAvailableAirPlayDevices(devices);
@@ -1147,13 +1220,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1147
1220
  }
1148
1221
  return [];
1149
1222
  }
1150
- }, [player]);
1223
+ }, [airPlaySupported, player, showTransientMessage]);
1151
1224
  const openAirPlaySettings = useCallback(() => {
1225
+ if (!airPlaySupported) {
1226
+ showTransientMessage(airPlayMacOSOnlyMessage);
1227
+ return;
1228
+ }
1152
1229
  const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
1153
1230
  selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
1154
1231
  go('airplay-settings', { resetSelection: false });
1155
1232
  void refreshAirPlayTargets(false);
1156
- }, [availableAirPlayDevices, go, refreshAirPlayTargets]);
1233
+ }, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]);
1157
1234
  const selectAirPlayDeviceAt = useCallback((index) => {
1158
1235
  const device = availableAirPlayDevices[index];
1159
1236
  if (!device) {
@@ -1357,7 +1434,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1357
1434
  }
1358
1435
  }
1359
1436
  const command = updateCommandForInstall();
1360
- const copied = copyToClipboard(command.command);
1437
+ const copied = await copyToClipboard(command.command);
1361
1438
  const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
1362
1439
  const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
1363
1440
  const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
@@ -1415,6 +1492,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1415
1492
  loadCountry,
1416
1493
  openAirPlaySettings,
1417
1494
  openLibrary,
1495
+ persistLibrary,
1418
1496
  player,
1419
1497
  playingStation,
1420
1498
  providers,
@@ -1471,6 +1549,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1471
1549
  }
1472
1550
  });
1473
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
+ }
1474
1555
  if (command.type === 'status')
1475
1556
  return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
1476
1557
  if (command.type === 'play') {
@@ -1498,7 +1579,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1498
1579
  }
1499
1580
  if (command.type === 'stop') {
1500
1581
  tuneRequestRef.current += 1;
1501
- setLibrary(store.finishActiveListeningSession());
1582
+ persistLibrary(() => store.finishActiveListeningSession());
1502
1583
  await player.stop();
1503
1584
  playingStationRef.current = null;
1504
1585
  setPlayingStation(null);
@@ -1508,7 +1589,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1508
1589
  }
1509
1590
  if (command.type === 'alarm-preempt') {
1510
1591
  tuneRequestRef.current += 1;
1511
- setLibrary(store.finishActiveListeningSession());
1592
+ persistLibrary(() => store.finishActiveListeningSession());
1512
1593
  await player.stop();
1513
1594
  playingStationRef.current = null;
1514
1595
  setPlayingStation(null);
@@ -1536,10 +1617,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1536
1617
  return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
1537
1618
  }
1538
1619
  if (command.type === 'airplay-list') {
1620
+ if (networkPolicy().offline)
1621
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
1539
1622
  const devices = await refreshAirPlayTargets(false);
1540
1623
  return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
1541
1624
  }
1542
1625
  if (command.type === 'airplay-select') {
1626
+ if (networkPolicy().offline)
1627
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
1543
1628
  const devices = await refreshAirPlayTargets(false);
1544
1629
  const device = devices.find(item => item.id === command.deviceId);
1545
1630
  if (!device)
@@ -1711,6 +1796,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1711
1796
  spinnerFrame
1712
1797
  });
1713
1798
  const basePageFooter = pageFooterText({
1799
+ airPlaySupported,
1714
1800
  canEnterAirPlayCode,
1715
1801
  capturingTransportAction,
1716
1802
  commandMode,
@@ -1730,7 +1816,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1730
1816
  (screen === 'search' && editingSearch) ||
1731
1817
  screen === 'airplay-code');
1732
1818
  const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
1733
- const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
1819
+ const statusMessage = message ?? persistenceWarning ?? presenceWarning;
1820
+ const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
1734
1821
  ? pageFooter
1735
1822
  : footerPlayback.state === 'loading' && playbackFooter
1736
1823
  ? playbackFooter
@@ -1739,17 +1826,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1739
1826
  : pageFooter);
1740
1827
  const compactGlobalFooter = '←/→ tabs · ? help · q quit';
1741
1828
  const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
1742
- const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1829
+ const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
1743
1830
  const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
1744
- const compactStatus = footerMessage ?? message ?? playbackFooter;
1831
+ const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
1745
1832
  const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
1746
1833
  const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
1747
1834
  const microLegendWidth = Math.max(1, frameWidth - versionReserve);
1748
- const microLegend = commandMode || capturingTransportAction || footerMessage || message
1835
+ const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
1749
1836
  ? microFooter
1750
1837
  : microShortcutFooterText(microFooter, microLegendWidth);
1751
1838
  const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
1752
- 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, 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 || message ? 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 || 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(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `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}`))] }) })) })] }) }) }));
1753
1840
  }
1754
1841
  function mcpRuntime() {
1755
1842
  return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
@@ -1777,12 +1864,12 @@ function buildLibraryStations(library) {
1777
1864
  for (const station of library.favorites) {
1778
1865
  addStation(station);
1779
1866
  }
1780
- for (const item of library.recent) {
1781
- addStation(item.station);
1782
- }
1783
1867
  for (const station of library.imported) {
1784
1868
  addStation(station);
1785
1869
  }
1870
+ for (const item of library.recent) {
1871
+ addStation(item.station);
1872
+ }
1786
1873
  return stations;
1787
1874
  }
1788
1875
  function exploreCursorLocation(cursor) {
@@ -1880,7 +1967,7 @@ function audioOutputSwitchLabel(output, backends) {
1880
1967
  return audioOutputLabel(output);
1881
1968
  }
1882
1969
  function librarySubtitle(library) {
1883
- 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`;
1884
1971
  }
1885
1972
  function mouseVisibleRows(screen, layout) {
1886
1973
  if (screen === 'help')